工程底座:单一生成入口、验证期写闸门与并行分片
- Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口; 格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门; 前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。 - 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。 - 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。 - 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。 - .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。
This commit is contained in:
parent
f76c3cd04a
commit
0658d178ad
6
.github/workflows/检查.yml
vendored
6
.github/workflows/检查.yml
vendored
@ -50,11 +50,15 @@ jobs:
|
|||||||
make 前端测试
|
make 前端测试
|
||||||
- name: 离线测试
|
- name: 离线测试
|
||||||
run: make 测试
|
run: make 测试
|
||||||
|
- name: 构建并验收同批安装包
|
||||||
|
run: |
|
||||||
|
make 构建
|
||||||
|
make 包验收 包=dist/muse-0.1.0-py3-none-any.whl
|
||||||
- name: 初始化隔离用途角色
|
- name: 初始化隔离用途角色
|
||||||
run: psql "$MUSE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f 数据库/初始化/用途角色.sql
|
run: psql "$MUSE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f 数据库/初始化/用途角色.sql
|
||||||
- name: 隔离数据库测试
|
- name: 隔离数据库测试
|
||||||
run: make 数据库测试
|
run: make 数据库测试
|
||||||
- name: 固定Pi宿主整链
|
- name: 固定Pi宿主整链
|
||||||
run: |
|
run: |
|
||||||
npm install --prefix "$RUNNER_TEMP/muse-pi" --no-save --ignore-scripts @earendil-works/pi-coding-agent@0.84.4
|
npm install --prefix "$RUNNER_TEMP/muse-pi" --no-save --ignore-scripts @earendil-works/pi-coding-agent@0.85.1
|
||||||
MUSE_PI_NODE="$(command -v node)" MUSE_PI_PACKAGE="$RUNNER_TEMP/muse-pi/node_modules/@earendil-works/pi-coding-agent" make 宿主测试
|
MUSE_PI_NODE="$(command -v node)" MUSE_PI_PACKAGE="$RUNNER_TEMP/muse-pi/node_modules/@earendil-works/pi-coding-agent" make 宿主测试
|
||||||
|
|||||||
6
.gitignore
vendored
6
.gitignore
vendored
@ -43,3 +43,9 @@ web/node_modules/
|
|||||||
web/dist/
|
web/dist/
|
||||||
web/test-results/
|
web/test-results/
|
||||||
web/playwright-report/
|
web/playwright-report/
|
||||||
|
|
||||||
|
# 内容增量构建缓存可删除重建,不作为发布权威
|
||||||
|
/构建缓存/
|
||||||
|
|
||||||
|
# TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪)
|
||||||
|
web/*.tsbuildinfo
|
||||||
|
|||||||
53
AGENTS.md
53
AGENTS.md
@ -1,6 +1,6 @@
|
|||||||
# AGENTS.md —— agent-example 项目工作入口
|
# AGENTS.md —— agent-example 项目工作入口
|
||||||
|
|
||||||
> 适用范围:本文件只约束 `agent-example/`。父仓 `../AGENTS.md`(原路径 ../AGENTS.md,已随旧实现退出,见固定基线) 的通用工程、证据和协作规则继续生效;本文件只补充本地创作仓的规则,不重复父仓规范。
|
> 适用范围:本文件只约束 `agent-example/`。用户及上级提供的通用工程规则继续生效;本仓所需的批次、验证与交付约定见[计划与提交](.agent/rules/计划与提交.md),不依赖检出目录外的规则文件。
|
||||||
> **技能发现入口(必读)**:读完本文件后必须读 [`.agent/skills/目录.md`](.agent/skills/目录.md)(技能目录);需要某项能力时,按目录的“相对地址”读取对应 `SKILL.md`。发现只靠 AGENTS.md → 技能目录 → SKILL.md 的渐进披露,不依赖任何宿主的技能自动发现。
|
> **技能发现入口(必读)**:读完本文件后必须读 [`.agent/skills/目录.md`](.agent/skills/目录.md)(技能目录);需要某项能力时,按目录的“相对地址”读取对应 `SKILL.md`。发现只靠 AGENTS.md → 技能目录 → SKILL.md 的渐进披露,不依赖任何宿主的技能自动发现。
|
||||||
> **作者创作入口(按需)**:作者用自然中文提出定故事、排故事、塑人物、续写、修改或诊断请求时,先读 [`.agent/作者/指令.md`](.agent/作者/指令.md),再只打开命中的一个场景文件;作者层负责路由,不替代正式技能、角色合同或用户确认。
|
> **作者创作入口(按需)**:作者用自然中文提出定故事、排故事、塑人物、续写、修改或诊断请求时,先读 [`.agent/作者/指令.md`](.agent/作者/指令.md),再只打开命中的一个场景文件;作者层负责路由,不替代正式技能、角色合同或用户确认。
|
||||||
|
|
||||||
@ -10,9 +10,9 @@
|
|||||||
|
|
||||||
`agent-example` 的目标定位是物理位于 `oh-my-muse` 内、拥有独立 `.git/` 的单用户缩小版 Muse,以 PostgreSQL 为正式内容权威。
|
`agent-example` 的目标定位是物理位于 `oh-my-muse` 内、拥有独立 `.git/` 的单用户缩小版 Muse,以 PostgreSQL 为正式内容权威。
|
||||||
|
|
||||||
- **正式内容权威**:PostgreSQL(`muse-example` 库)是系统正式内容的唯一权威。作品、章、正文、实体、范式、用户决策、运行回执和 raw 都在库里。判断“系统里有没有这个东西”,以库里能不能查到为准;严禁把库外临时文件或快照当作正式内容。
|
- **正式内容权威**:PostgreSQL(连接目标由当前运行配置明确选择,见[运行手册](docs/运行手册.md))是系统正式内容的唯一权威。作品、章、正文、实体、方法、用户决策及运行回执在库里。raw暂存文件受租约管理,归档后以PG归档及回执为准;私人探索文件不作为正式内容。判断“系统里有没有这个东西”,以库里能不能查到为准;严禁把库外临时文件或快照当作正式内容。
|
||||||
- **运行态账本**:本地 SQLite(`data/muse.db`,gitignore)是默认派发与单机工作面的运行态账本(2026-08-28 存储裁决):运行留痕(runs/events)、本地人审(reviews/revisions)、卡片与向量镜像、lesson 本地状态机和外部反馈数据落在其中;它不产生 Canonical,与 PG 冲突时以 PG 为准。表清单见 [`muse/authority/db/表映射.md`](docs/系统架构/新版设计/数据模型/存储与对象关系.md)。
|
- **运行态账本**:任务、步骤、调用、预算、事件与回执由 PostgreSQL 维护;旧 SQLite 账本只属于历史实现。对象归属见[存储与对象关系](docs/系统架构/新版设计/数据模型/存储与对象关系.md)。
|
||||||
- **代码与配置权威**:Git 是代码、技能、智能体提示词、`muse/content/meta/schemas/`、文档和 DDL 的权威,并对作品信息和文本留痕备份;但 Git 留痕不是正式内容权威,正式内容以数据库为准,只读看板只读库,两者冲突时以库为准。
|
- **代码与配置权威**:Git 维护代码、技能、角色提示词、内置结构种子、文档和 DDL。`src/muse/元数据/内置结构/` 的 YAML 是发布输入;运行结构以 PostgreSQL 已发布版本为准。Git 不承担创作数据备份。
|
||||||
- **能力边界**:由主会话派发角色智能体、技能和确定性工具协作完成创作与治理;不实现管理员、多用户、租户、市场、计费或资产交易。
|
- **能力边界**:由主会话派发角色智能体、技能和确定性工具协作完成创作与治理;不实现管理员、多用户、租户、市场、计费或资产交易。
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -28,6 +28,10 @@ agent-example/
|
|||||||
├── 配置/ # 无凭据配置模板
|
├── 配置/ # 无凭据配置模板
|
||||||
├── web/ # 前端:React/TS 用户工作台
|
├── web/ # 前端:React/TS 用户工作台
|
||||||
├── 数据库/ # 数据库迁移脚本与旧库迁移工具链
|
├── 数据库/ # 数据库迁移脚本与旧库迁移工具链
|
||||||
|
├── 部署/ # 部署模板与进程配置
|
||||||
|
├── upstream/ # 固定版本的上游协议资料
|
||||||
|
├── .github/ # CI 工作流
|
||||||
|
├── data/ # 运行文件(不跟踪,不作为源码)
|
||||||
├── .agent/ # 智能体能力中枢(作者入口、角色提示词、角色合同、方法技能、规则、约束与规范)
|
├── .agent/ # 智能体能力中枢(作者入口、角色提示词、角色合同、方法技能、规则、约束与规范)
|
||||||
├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录
|
├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录
|
||||||
├── .venv/ # 仓内解释器(uv 管理)
|
├── .venv/ # 仓内解释器(uv 管理)
|
||||||
@ -45,41 +49,40 @@ SoT 按主题分域,不做跨主题的全局排序。可执行脚本与书面
|
|||||||
|
|
||||||
| 类别 | 载体 / 路径 | 权威职责 |
|
| 类别 | 载体 / 路径 | 权威职责 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 总体设计 | `../design-docs/`(原路径 ../design-docs,已随旧实现退出,见固定基线) | Muse 的概念、产品、业务和总体架构 SoT(设计 SSOT)。 |
|
| 总体设计 | [总体架构](docs/系统架构/新版设计/总体架构.md) | 本仓模块、对象归属、依赖和运行边界;外部历史设计不充当当前实现。 |
|
||||||
| 新版重写目标 | [docs/系统架构/新版设计/目录.md](docs/系统架构/新版设计/目录.md) | 新版主体完整目标设计、元数据、文件职责及研究依据;不据此宣称现有实现已完成。 |
|
| 新版重写目标 | [docs/系统架构/新版设计/目录.md](docs/系统架构/新版设计/目录.md) | 新版主体完整目标设计、元数据、文件职责及研究依据;不据此宣称现有实现已完成。 |
|
||||||
| 领域 SoT | [`muse/sot/domains/`](docs/系统架构/新版设计/阅读指南.md) | 本仓各业务领域边界、数据权威、落库合同与领域协作 SoT(01-08 域)。 |
|
| 领域 SoT | [阅读指南](docs/系统架构/新版设计/阅读指南.md) | 按主题定位现行业务领域、数据权威与协作合同。 |
|
||||||
| 边界合同 | [`muse/sot/边界合同.md`](docs/系统架构/新版设计/总体架构.md) | 组件职责边界与约束归属唯一事实源:智能体/技能/工具 server/主代理职责划分。 |
|
| 边界合同 | [总体架构](docs/系统架构/新版设计/总体架构.md) | 组件职责边界、跨域公开接口与约束归属。 |
|
||||||
| 角色合同 | [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md) | 5 个角色(写手/规划/抽取/检测/裁判)的稳定输入边界、模型策略、工具权限与派发合同。 |
|
| 角色合同 | [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md) | 5 个角色(写手/规划/抽取/检测/裁判)的稳定输入边界、模型策略、工具权限与派发合同。 |
|
||||||
| 结构契约 | `muse/content/meta/schemas/`(原路径 muse/content/meta/schemas,已随旧实现退出,见固定基线) | 23 型结构本体与规划产物的字段合同;库内 payload 结构以此为准。 |
|
| 结构契约 | [内置结构](src/muse/元数据/内置结构/) | 24 型的内置种子与发布输入;公共字段不是独立类型。运行实例遵守库内已发布结构版本。 |
|
||||||
| 创作导读 | [`muse/sot/创作周期与Skill导读.md`](docs/系统架构/新版设计/调用链路/关键旅程.md) | 创作生命周期各阶段流转、门禁、人机分界与技能责任方导读地图。 |
|
| 创作导读 | [关键旅程](docs/系统架构/新版设计/调用链路/关键旅程.md) | 创作流转、门禁、人机边界及正式内容的确认路径。 |
|
||||||
| 技能目录 | [`.agent/skills/目录.md`](.agent/skills/目录.md) / [`muse/技能目录.md`](.agent/skills/目录.md) | 59 个技能的方法目录与编排目录(物理清单以 `skills.json` 为准)。 |
|
| 技能目录 | [技能目录](.agent/skills/目录.md) | 方法和操作入口;以各组实际 SKILL.md 及目录发现,不维护另一份虚构清单。 |
|
||||||
| 创作链条 | `muse/lifecycle/flow/chains/`(原路径 muse/lifecycle/flow/chains,已随旧实现退出,见固定基线) | scenario、purpose、功能 skill、角色槽位和保护节点的链路登记。 |
|
| 创作链条 | [流程登记](src/muse/编排/流程登记.py)、[流程版本](src/muse/编排/流程版本.py)、[槽位约束](src/muse/编排/槽位约束.py) | 当前流程模板、冻结版本和角色槽位约束;旧 muse/lifecycle/flow/chains 已退出。 |
|
||||||
| 红线约束 | [`.agent/约束/红线约束.md`](.agent/约束/作者主权.md) | 数据权威、探索边界、模型治理与代码提交的不可逾越红线。 |
|
| 红线约束 | [作者主权](.agent/约束/作者主权.md)、[约束目录](.agent/约束/目录.md) | 数据权威、作者确认、读取范围与外部调用边界。 |
|
||||||
| 执行流程 | [`.agent/rules/执行流程.md`](.agent/rules/模块依赖.md) | 按条件适用的研发职责、分层验证与批次收尾;任务与依赖编排见[计划与提交](.agent/rules/计划与提交.md)。 |
|
| 执行流程 | [模块依赖](.agent/rules/模块依赖.md)、[计划与提交](.agent/rules/计划与提交.md) | 模块边界、任务依赖、分层验证和批次收尾。 |
|
||||||
| 子代理交付 | [`.agent/rules/子代理交付.md`](.agent/rules/子代理交付.md) | 派发子代理的交付物形状:三要素交付、证据指针、仅阻断级汇报、最小必读。 |
|
| 子代理交付 | [`.agent/rules/子代理交付.md`](.agent/rules/子代理交付.md) | 派发子代理的交付物形状:三要素交付、证据指针、仅阻断级汇报、最小必读。 |
|
||||||
| 审查标准 | [`.agent/规范/审查标准.md`](.agent/rules/文档与资源生成.md) | 智能体提示词 4 问、技能质量 7 问 + D8 复利审查规程与严重度定义。 |
|
| 审查标准 | [审查标准](.agent/规范/审查标准.md) | 由专项检查附录确认的提示词四问、技能七问和严重度;不冒称恢复缺失的旧 D8 原文。 |
|
||||||
| 工程规范 | [`.agent/规范/`](.agent/规范/) | [`去AI味道工程规范.md`](.agent/rules/审校与修订.md)、[`指令集规范.md`](.agent/规范/命名.md)、[`术语规范.md`](.agent/规范/术语.md)。 |
|
| 工程规范 | [规范目录](.agent/规范/目录.md)、[审校与修订](.agent/rules/审校与修订.md) | 命名、术语、中文正文及受控修订的现行规范。 |
|
||||||
| 任务与沉淀 | [`docs/`](docs) | 单次任务探索、计划、评测资料与历史执行证据;稳定结论回填 SoT。 |
|
| 任务与沉淀 | [`docs/`](docs) | 单次任务探索、计划、评测资料与历史执行证据;稳定结论回填 SoT。 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 工作协议(硬约束)
|
## 4. 工作协议(硬约束)
|
||||||
|
|
||||||
1. **读后动手与渐进发现**:复杂任务开工前必须先读 SoT、角色合同与技能索引;技能发现遵循 `AGENTS.md → 索引 → SKILL.md` 渐进展开,不依赖宿主私有文件投影或猜测。
|
1. **读后动手与渐进发现**:复杂任务先读对应 SoT;涉及角色时读角色合同。技能发现遵守本文件开头的唯一入口,按当前任务展开,不预载无关资料。
|
||||||
2. **机械验证优先与完成=验证**:遵循父仓反假绿规则,新版工程统一使用仓内解释器 `.venv`(uv 管理;旧代码用 `.venv-旧`);日常入口 `make 检查`(格式+类型+模块边界+索引)、`make 测试`(默认离线进程隔离)、`make 数据库测试`(需 `MUSE_TEST_DATABASE_URL`);必须通过相关单元测试、门禁与 `git diff --check`,无自动化证据严禁声称“完成/修复/通过”。
|
2. **机械验证优先与完成=验证**:工程统一使用仓内解释器 `.venv`(uv 管理);日常入口 `make 快检 范围=<受影响路径>`(开发期)、`make 检查`(格式+类型+模块边界+索引)、`make 测试`(默认离线进程隔离)、`make 数据库测试`(需 `MUSE_TEST_DATABASE_URL`);改完源码后的库层全量走 `make 验收数据库`(唯一入口,先生成再门禁后跑库),发现期要并行用 `make 数据库分片`,浏览器层用例走 `make 浏览器测试`(需隔离库与 `MUSE_BROWSER_EXECUTABLE`)。必须通过相关单元测试、门禁与 `git diff --check`,无自动化证据严禁声称“完成/修复/通过”。
|
||||||
3. **数据权威与先审后入**:数据库为唯一正式权威,严禁裸连操作;正文、规划与知识抽取默认生成 Shadow 候选,经用户明确确认后方可写入 Canonical 正典事实。
|
3. **数据权威与先审后入**:数据库为唯一正式权威,严禁裸连操作;正文、规划与知识抽取默认生成 Shadow 候选,经用户明确确认后方可写入 Canonical 正典事实。
|
||||||
4. **模型治理与受控探索**:模型调用严格遵守 5 小时额度窗口与受控治理链,角色严格锁定[角色合同](.agent/角色/角色合同.md)指定模型(写手/规划固定顶级推理模型,裁判使用独立精确白名单);确定性逻辑、门禁与报告组装由脚本完成,严禁调用模型;智能体探索仅限圈定只读工具并留痕。
|
4. **模型治理与受控探索**:模型调用遵守[预算管理](src/muse/任务运行/预算管理.py)的固定日界窗口(默认 Asia/Shanghai,每日00/05/10/15/20开始,末窗20至24为4小时)与受控治理链;角色允许模型与策略版本以[角色策略](配置/角色策略.yaml)为准,并遵守[角色合同](.agent/角色/角色合同.md)(写手/规划固定顶级推理模型,裁判使用独立精确白名单);确定性逻辑、门禁与报告组装由脚本完成,严禁调用模型;智能体探索仅限圈定只读工具并留痕。
|
||||||
5. **会话交互与汇报纪律**:全程使用简体中文白话,坚决去除 AI 味(直陈事实、动作与后果,禁止清嗓子套话与空转缓冲词);需要用户决策时,必须交代清楚前因后果及各选项对下游的影响。
|
5. **会话交互与汇报纪律**:全程使用简体中文白话,坚决去除 AI 味(直陈事实、动作与后果,禁止清嗓子套话与空转缓冲词);需要用户决策时,必须交代清楚前因后果及各选项对下游的影响。
|
||||||
6. **提交授权与形而上审查**:严禁未经用户明确授权执行 `git add` 或 `git commit`;按[计划与提交](.agent/rules/计划与提交.md)预先确定交付批次,L3 重任务批次(判据见 `../.agents/rules/verification-principles.md`:跨模块、超过 2 个文件、修改超 100 行、核心逻辑变动、不可逆操作,任一命中)门禁通过后由独立只读子代理完成四维整体审查,通过前不得宣布该批完成,L1/L2 批次以门禁绿加抽查代替;不逐笔提交叠加审查,必要的事前方案评审、修复重审和最终集成验收仍保留;框架与创作内容分开审查、分开提交。
|
6. **提交授权与整体审查**:严禁未经用户明确授权执行 `git add` 或 `git commit`;按[计划与提交](.agent/rules/计划与提交.md)预先确定交付批次,L3 重任务批次(判据及验证分层见同一规则)门禁通过后由独立只读子代理完成四维整体审查,通过前不得宣布该批完成,L1/L2 批次以门禁绿加抽查代替;不逐笔提交叠加审查,必要的事前方案评审、修复重审和最终集成验收仍保留;框架与创作内容分开审查、分开提交。
|
||||||
7. **计划边界与提交节奏**:以可独立验收的完整结果拆任务,明确边界、依赖、维护者与验证方式;前置满足且有收益的独立任务分工并发,依赖任务按已验证能力衔接;验证不过不收尾,获授权后每个完整任务单独提交。具体编排、隔离和批次收尾以[计划与提交](.agent/rules/计划与提交.md)为准。
|
7. **计划边界与提交节奏**:以可独立验收的完整结果拆任务,明确边界、依赖、维护者与验证方式;前置满足且有收益的独立任务分工并发,依赖任务按已验证能力衔接;验证不过不收尾,获授权后每个完整任务单独提交。具体编排、隔离和批次收尾以[计划与提交](.agent/rules/计划与提交.md)为准。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 知识与经验复利机制
|
## 5. 交付与经验沉淀
|
||||||
|
|
||||||
系统的核心价值在于复利:每一次创作与开发任务后,系统必须比上一次更强。
|
- 创作方法和规则沿[审校修订](docs/系统架构/新版设计/模块设计/B06-审校修订.md)、[作者经验](docs/系统架构/新版设计/模块设计/B07-作者经验.md)的候选、评测与批准链承接,不自动写入正式内容或沿用旧豁免。
|
||||||
- **创作复利**:创作过程中提炼的技法沉淀为范式卡、AI 味规则、声音账与 `example_lesson` 登记入库,走 [06-质量与复利领域 §6](docs/系统架构/新版设计/模块设计/B06-审校修订.md) 升格链。
|
- 工程交付按[计划与提交](.agent/rules/计划与提交.md)同步所属权威合同,按[文档与资源生成](.agent/rules/文档与资源生成.md)维护受影响索引并验证,清理被替代实现、重复路径、旧配置与无用途测试;有兼容、迁移、回滚或追溯用途的内容保留并注明用途。
|
||||||
- **工程复利**:开发交付后,将稳定设计与规则回写对应 SoT、`.agent/规范/`、`.agent/约束/` 或技能,过时内容及时清理,控制长期熵增。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -97,8 +100,8 @@ SoT 按主题分域,不做跨主题的全局排序。可执行脚本与书面
|
|||||||
|
|
||||||
### 维护纪律
|
### 维护纪律
|
||||||
|
|
||||||
处理任何代码变更时,以项目已有架构、领域模型、ADR、编码规范和测试为准,优先保持代码可读、简单、局部且可维护,禁止无证据叠加抽象;新方案完成后必须清理被替代的实现、重复路径、旧配置、旧测试和文档残留,控制长期熵增。
|
代码变更以现行架构、领域模型、ADR、规范与测试为准,保持可读、简单、局部,不凭空叠加抽象;清理和沉淀按本文件§5执行。
|
||||||
|
|
||||||
提交:`AGENTS.md`、`docs/`、`.agent/` 全部内容。
|
纳入版本控制:`AGENTS.md`、`docs/`、`.agent/`;实际暂存和提交仍须明确授权。
|
||||||
不提交:`.agents.local/`、`.claude/`、`.codex/`、`.pi/`、`.opencode/`、`.cursor/`。
|
不提交:`.agents.local/`、`.claude/`、`.codex/`、`.pi/`、`.opencode/`、`.cursor/`。
|
||||||
<!-- my-skills-cli:end -->
|
<!-- my-skills-cli:end -->
|
||||||
|
|||||||
95
Makefile
95
Makefile
@ -1,7 +1,13 @@
|
|||||||
|
export PYTHONPATH := $(CURDIR)/src:$(CURDIR)/工具
|
||||||
|
|
||||||
|
# 用例与库层直接用仓内解释器:uv run 在嵌套检出里会解析到外层 agent-example/.venv,
|
||||||
|
# 那个环境没有 psycopg_pool,库层会以导入失败告终。工具链目标仍走 uv run。
|
||||||
|
仓内解释器 := $(CURDIR)/.venv/bin/python
|
||||||
|
|
||||||
# Muse 安装、生成、检查、测试和构建入口。
|
# Muse 安装、生成、检查、测试和构建入口。
|
||||||
# 每个声明命令都必须真实可执行(见 .agent/rules/文档与资源生成.md)。
|
# 每个声明命令都必须真实可执行(见 .agent/rules/文档与资源生成.md)。
|
||||||
|
|
||||||
.PHONY: 安装 格式 格式写入 类型 模块边界 检查 测试 数据库测试 宿主测试 真实模型测试 前端安装 前端检查 前端测试 生成 构建 资源核对
|
.PHONY: 迁移 前端旅程 快检 索引生成 包验收 安装 格式 格式写入 类型 模块边界 检查 测试 数据库测试 数据库分片 验收数据库 浏览器测试 宿主测试 真实模型测试 前端安装 前端检查 前端测试 生成 构建 资源核对
|
||||||
|
|
||||||
# 安装:建立并锁定 Python 依赖环境
|
# 安装:建立并锁定 Python 依赖环境
|
||||||
安装:
|
安装:
|
||||||
@ -18,65 +24,104 @@
|
|||||||
前端测试:
|
前端测试:
|
||||||
pnpm --dir web run test
|
pnpm --dir web run test
|
||||||
|
|
||||||
|
# 浏览器旅程需调用者明确选择真实后端与浏览器;不属于默认离线层。
|
||||||
|
前端旅程:
|
||||||
|
@test -n "$$MUSE_WORKBENCH_URL" -a -n "$$MUSE_BROWSER_EXECUTABLE" || { echo "缺少明确的 MUSE_WORKBENCH_URL / MUSE_BROWSER_EXECUTABLE" >&2; exit 1; }
|
||||||
|
@test -x "$$MUSE_BROWSER_EXECUTABLE" || { echo "MUSE_BROWSER_EXECUTABLE 不是可执行浏览器路径" >&2; exit 1; }
|
||||||
|
@test "$$MUSE_ISOLATED_TEST_ENVIRONMENT" = "1" || { echo "缺 MUSE_ISOLATED_TEST_ENVIRONMENT=1(旅程环境在配置加载期强校验)" >&2; exit 1; }
|
||||||
|
@test -n "$$MUSE_AUTHOR_PASSWORD_FILE" -a -f "$$MUSE_AUTHOR_PASSWORD_FILE" || { echo "MUSE_AUTHOR_PASSWORD_FILE 必须是可读的绝对路径" >&2; exit 1; }
|
||||||
|
pnpm --dir web run test:journey $(参数)
|
||||||
|
|
||||||
# 格式:静态检查(ruff lint + 格式校验;只覆盖新版测试与工具目录)
|
# 格式:静态检查(ruff lint + 格式校验;只覆盖新版测试与工具目录)
|
||||||
新测试目录 := $(shell for d in tests/单元 tests/契约 tests/集成 tests/架构 tests/迁移 tests/端到端 tests/真实调用; do [ -d "$$d" ] && printf '%s ' "$$d"; done)
|
新测试目录 := $(shell for d in tests/单元 tests/契约 tests/集成 tests/架构 tests/迁移 tests/端到端 tests/真实调用; do [ -d "$$d" ] && printf '%s ' "$$d"; done)
|
||||||
新工具目录 := 工具 $(wildcard 数据库/旧库迁移)
|
新工具目录 := 工具 $(wildcard 数据库/旧库迁移)
|
||||||
格式:
|
格式:
|
||||||
uv run ruff check src $(新测试目录) tests/conftest.py $(新工具目录)
|
uv run --no-sync ruff check src $(新测试目录) tests/conftest.py $(新工具目录)
|
||||||
uv run ruff format --check src $(新测试目录) tests/conftest.py $(新工具目录)
|
uv run --no-sync ruff format --check src $(新测试目录) tests/conftest.py $(新工具目录)
|
||||||
|
|
||||||
# 格式写入:按项目规则实际格式化
|
# 格式写入:按项目规则实际格式化;格式化同样改源码,就地重新生成,避免之后整层报身份不符
|
||||||
|
# 每步用 --执行 在独占锁内完成:检查通过后到写入前不再有可被验证会话插入的窗口。
|
||||||
格式写入:
|
格式写入:
|
||||||
uv run ruff check --fix src $(新测试目录) tests/conftest.py $(新工具目录)
|
uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync ruff check --fix src $(新测试目录) tests/conftest.py $(新工具目录)
|
||||||
uv run ruff format src $(新测试目录) tests/conftest.py $(新工具目录)
|
uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync ruff format src $(新测试目录) tests/conftest.py $(新工具目录)
|
||||||
|
$(MAKE) 生成
|
||||||
|
|
||||||
# 类型:pyright 类型检查
|
# 类型:pyright 类型检查
|
||||||
类型:
|
类型:
|
||||||
uv run pyright
|
uv run --no-sync pyright
|
||||||
|
|
||||||
# 模块边界:固定执行架构门禁,不继承调用者的 pytest 筛选条件。
|
# 模块边界:固定执行架构门禁,不继承调用者的 pytest 筛选条件。
|
||||||
模块边界:
|
模块边界:
|
||||||
PYTEST_ADDOPTS= uv run pytest tests/架构/test_模块依赖.py -q
|
PYTEST_ADDOPTS= $(仓内解释器) -m pytest tests/架构/test_模块依赖.py -m "not 安装包" -q
|
||||||
|
|
||||||
# 检查:格式 + 类型 + 模块边界 + 索引核对的统一入口
|
# 检查:格式 + 类型 + 模块边界 + 索引核对的统一入口
|
||||||
检查: 格式 类型 模块边界
|
检查: 格式 类型 模块边界
|
||||||
uv run python 工具/维护索引.py --检查
|
uv run --no-sync python 工具/维护索引.py --检查
|
||||||
|
|
||||||
# 测试:默认离线(按标记剔除外部环境用例;未标记用例在 socket 防护下运行)
|
# 测试:默认离线(按标记剔除外部环境用例;未标记用例在 socket 防护下运行)
|
||||||
# 用法:make 测试 全部离线用例
|
# 用法:make 测试 全部离线用例
|
||||||
# make 测试 用例=TC-a5c7d4adafe3 按稳定用例 ID 选择
|
# make 测试 用例=TC-a5c7d4adafe3 按稳定用例 ID 选择
|
||||||
测试:
|
测试:
|
||||||
uv run pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主" $(if $(用例),--case $(用例),)
|
$(仓内解释器) -m pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主 and not 安装包" $(范围) $(if $(用例),--case $(用例),)
|
||||||
|
|
||||||
# 数据库测试:需要 MUSE_TEST_DATABASE_URL;缺失即失败,不静默跳过
|
# 验收数据库:改完源码后唯一该跑的全量入口。先生成、再门禁、最后跑库,
|
||||||
|
# 顺序由目标固定,避免“改了 src 没重新生成”这类整轮作废。
|
||||||
|
验收数据库:
|
||||||
|
@test -n "$$MUSE_TEST_DATABASE_URL" || { echo "缺少 MUSE_TEST_DATABASE_URL(隔离库连接串);拒绝先付生成与门禁成本再失败" >&2; exit 1; }
|
||||||
|
$(MAKE) 生成
|
||||||
|
$(MAKE) 检查
|
||||||
|
$(仓内解释器) -m pytest --外部环境 -m "数据库 and not 宿主 and not 真实模型 and not 浏览器 and not 网络" $(范围)
|
||||||
|
|
||||||
|
# 数据库分片:发现与修复期用分片并行,全量只跑一次做最终判定
|
||||||
|
数据库分片:
|
||||||
|
$(仓内解释器) 工具/并行数据库测试.py --片数 $(if $(片数),$(片数),3) $(范围)
|
||||||
|
|
||||||
|
# 数据库测试:需要 MUSE_TEST_DATABASE_URL;缺失即失败,不静默跳过。
|
||||||
|
# 与 验收数据库 同口径排除 浏览器/网络:浏览器用例另有 浏览器测试 入口,不该在这里被静默 skip。
|
||||||
数据库测试:
|
数据库测试:
|
||||||
@test -n "$$MUSE_TEST_DATABASE_URL" || { echo "缺少 MUSE_TEST_DATABASE_URL(隔离库连接串);拒绝静默跳过" >&2; exit 1; }
|
@test -n "$$MUSE_TEST_DATABASE_URL" || { echo "缺少 MUSE_TEST_DATABASE_URL(隔离库连接串);拒绝静默跳过" >&2; exit 1; }
|
||||||
uv run pytest --外部环境 -m "数据库 and not 宿主 and not 真实模型"
|
$(仓内解释器) -m pytest --外部环境 -m "数据库 and not 宿主 and not 真实模型 and not 浏览器 and not 网络" $(范围) $(if $(用例),--case $(用例),)
|
||||||
|
|
||||||
|
# 浏览器测试:需要隔离库与显式浏览器路径;11 个 数据库+浏览器 用例的唯一入口。
|
||||||
|
# MUSE_ISOLATED_TEST_ENVIRONMENT 由本目标声明:Playwright 配置加载期就要求它,缺了连 spec 都跑不到。
|
||||||
|
浏览器测试:
|
||||||
|
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_BROWSER_EXECUTABLE" || { echo "缺少隔离库或 MUSE_BROWSER_EXECUTABLE" >&2; exit 1; }
|
||||||
|
MUSE_RUN_BROWSER=1 MUSE_ISOLATED_TEST_ENVIRONMENT=1 $(仓内解释器) -m pytest --外部环境 -m "浏览器 and not 宿主 and not 真实模型" $(范围)
|
||||||
|
|
||||||
真实模型测试:
|
真实模型测试:
|
||||||
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_REAL_MODEL_URL" -a -n "$$MUSE_REAL_MODEL_KEY_FILE" || { echo "缺少隔离库或真实模型地址/凭据文件"; exit 1; }
|
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_REAL_MODEL_URL" -a -n "$$MUSE_REAL_MODEL_KEY_FILE" || { echo "缺少隔离库或真实模型地址/凭据文件"; exit 1; }
|
||||||
uv run pytest --外部环境 -m 真实模型 --timeout=300
|
$(仓内解释器) -m pytest --外部环境 -m 真实模型 --timeout=300
|
||||||
|
|
||||||
宿主测试:
|
宿主测试:
|
||||||
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_PI_NODE" -a -n "$$MUSE_PI_PACKAGE" || { echo "缺少隔离库或固定Pi/Node路径"; exit 1; }
|
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_PI_NODE" -a -n "$$MUSE_PI_PACKAGE" || { echo "缺少隔离库或固定Pi/Node路径"; exit 1; }
|
||||||
uv run pytest --外部环境 -m 宿主
|
$(仓内解释器) -m pytest --外部环境 -m 宿主
|
||||||
|
|
||||||
# 迁移:对配置指向的目标库执行显式迁移(维护命令,不在启动时自动执行)
|
# 迁移:对配置指向的目标库执行显式迁移(维护命令,不在启动时自动执行)
|
||||||
迁移:
|
迁移:
|
||||||
uv run python -m muse 迁移 $(配置)
|
uv run --no-sync python -m muse 迁移 $(配置)
|
||||||
|
|
||||||
# 生成:资源包、接口合同与索引(生成物只由登记输入产生)
|
# 快检只检查指定Python源码/测试;不构建资源或前端。
|
||||||
|
# 范围写成具体路径(如 `范围=src/muse/元数据`、`范围=tests/单元`):
|
||||||
|
# 含非测试路径就跑类型门,含测试路径才跑用例门;范围缺省时按源码 + 单元/契约用例。
|
||||||
|
快检:
|
||||||
|
uv run --no-sync ruff check $(if $(范围),$(范围),src tests 工具)
|
||||||
|
uv run --no-sync ruff format --check $(if $(范围),$(范围),src tests 工具)
|
||||||
|
$(if $(范围),$(if $(filter-out tests/%,$(范围)),uv run --no-sync pyright,:),uv run --no-sync pyright)
|
||||||
|
$(if $(filter tests/%,$(范围)),$(仓内解释器) -m pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主 and not 安装包" $(filter tests/%,$(范围)) $(if $(用例),--case $(用例),),$(if $(范围),:,$(仓内解释器) -m pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主 and not 安装包" tests/单元 tests/契约 $(if $(用例),--case $(用例),)))
|
||||||
|
|
||||||
|
索引生成:
|
||||||
|
uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync python 工具/维护索引.py --写入
|
||||||
|
|
||||||
|
# 聚合入口按内容哈希增量执行;wheel必须与同次资源输入绑定。
|
||||||
生成:
|
生成:
|
||||||
uv run python 工具/生成接口.py
|
uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync python 工具/构建编排.py
|
||||||
cd web && pnpm exec openapi-typescript ../docs/接口契约/生成/openapi.generated.json -o src/接口/生成/类型.ts
|
|
||||||
pnpm --dir web run build
|
|
||||||
uv run python 工具/构建资源包.py
|
|
||||||
uv run python 工具/维护索引.py --写入
|
|
||||||
|
|
||||||
# 构建:先保证生成物最新,再做可安装包
|
构建:
|
||||||
构建: 生成
|
uv run --no-sync python 工具/构建编排.py --wheel
|
||||||
uv build
|
|
||||||
|
包验收:
|
||||||
|
uv run --no-sync python 工具/验收安装包.py $(包)
|
||||||
|
|
||||||
# 资源核对:安装包内资源与清单哈希一致
|
# 资源核对:安装包内资源与清单哈希一致
|
||||||
资源核对:
|
资源核对:
|
||||||
uv run python -c "from muse.资源加载 import 核对资源; m = 核对资源(); print('资源清单发布身份:', m['发布身份'], '条目数:', len(m['资源']))"
|
uv run --no-sync python -c "from muse.资源加载 import 核对资源; m = 核对资源(); print('资源清单发布身份:', m['发布身份'], '条目数:', len(m['资源']))"
|
||||||
|
|||||||
27
README.md
27
README.md
@ -123,7 +123,7 @@ muse 审校 <作者配置.toml> 返修比较证明 <比较回执ID>
|
|||||||
|
|
||||||
来源请求固定返修会话、轮次、实际候选、批准引用和有效期;实验请求固定数据集及哈希、两份独立judge配置、维度和预算。请求结构见[B10固定返修比较合同](docs/系统架构/新版设计/模块设计/B10-效果评测.md)。评测任务也可在evaluation工作台按“执行下一步”推进,每次只执行一个冻结步骤;缺少计价仍记录未知费用。
|
来源请求固定返修会话、轮次、实际候选、批准引用和有效期;实验请求固定数据集及哈希、两份独立judge配置、维度和预算。请求结构见[B10固定返修比较合同](docs/系统架构/新版设计/模块设计/B10-效果评测.md)。评测任务也可在evaluation工作台按“执行下一步”推进,每次只执行一个冻结步骤;缺少计价仍记录未知费用。
|
||||||
|
|
||||||
返修页可按回执读取比较记录、未完整或未标定原因,作者仍可保留原文。维护/来源连接只由本地显式配置选择,HTTP不接受服务器凭据路径。当前发布策略只有单一固定评委模型,真实双模型执行须先解决角色策略冲突;隔离合成测试不替代真实模型验收,也不授予生产启用许可。
|
返修页可按回执读取比较记录、未完整或未标定原因,作者仍可保留原文。维护/来源连接只由本地显式配置选择,HTTP不接受服务器凭据路径。当前允许模型以[角色策略](配置/角色策略.yaml)为准;默认初评名单已有两项,独立仲裁所需的第三模型仍需登记精确身份和验证能力。验证范围见[验证纪律](#验证纪律)。
|
||||||
|
|
||||||
## 新库配置与迁移
|
## 新库配置与迁移
|
||||||
|
|
||||||
@ -143,9 +143,9 @@ muse 审校 <作者配置.toml> 返修比较证明 <比较回执ID>
|
|||||||
## 参考分析与历史承接
|
## 参考分析与历史承接
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run muse 研究 /private/作者配置.toml 进度 <来源ID>
|
.venv/bin/python -m muse 研究 /private/作者配置.toml 进度 <来源ID>
|
||||||
uv run muse 研究 /private/作者配置.toml 分析 <分析ID>
|
.venv/bin/python -m muse 研究 /private/作者配置.toml 分析 <分析ID>
|
||||||
uv run muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID>
|
.venv/bin/python -m muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID>
|
||||||
```
|
```
|
||||||
|
|
||||||
完整分析结果由B03保存。S02只保存执行证据与分析引用;旧任务显示`history_not_imported`时,明确运行承接命令才能转存,查询不会隐式写库。承接核原来源、全部窗口和归并,不创建任务或重跑模型。旧参考卡经[旧库迁移入口](docs/系统架构/新版设计/数据模型/旧知识记录分流.md)导入为未决分析;保存或模型分析完成都不等于作者确认、当前生成资格或真实资料迁移验收。
|
完整分析结果由B03保存。S02只保存执行证据与分析引用;旧任务显示`history_not_imported`时,明确运行承接命令才能转存,查询不会隐式写库。承接核原来源、全部窗口和归并,不创建任务或重跑模型。旧参考卡经[旧库迁移入口](docs/系统架构/新版设计/数据模型/旧知识记录分流.md)导入为未决分析;保存或模型分析完成都不等于作者确认、当前生成资格或真实资料迁移验收。
|
||||||
@ -154,6 +154,10 @@ uv run muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID>
|
|||||||
|
|
||||||
旧实现(`muse/` 主体、`runtime/`、`framework/`、`tests/skills/` 等旧代码与测试)已完全退出;`muse/sot/角色合同.md` 已迁入 [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md)。历史脉络与固定 Git 基线见[R2 改造历史留痕](docs/实现回顾/R2改造历史留痕.md),不再作为运行入口。旧库默认只作历史参照;代码退出不证明当前实际写权限已经关闭。
|
旧实现(`muse/` 主体、`runtime/`、`framework/`、`tests/skills/` 等旧代码与测试)已完全退出;`muse/sot/角色合同.md` 已迁入 [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md)。历史脉络与固定 Git 基线见[R2 改造历史留痕](docs/实现回顾/R2改造历史留痕.md),不再作为运行入口。旧库默认只作历史参照;代码退出不证明当前实际写权限已经关闭。
|
||||||
|
|
||||||
|
## 当前运行底座
|
||||||
|
|
||||||
|
[数据库连接](src/muse/基础设施/数据库/连接.py)按用途构造受控连接及其生命周期,运行装配走常驻连接池(`启动.py` 传 `常驻=True`,池在装配生命周期内打开、退出时关闭;缺 `psycopg_pool` 时明确报「连接池未就绪」,不静默降级);[可观测性](src/muse/基础设施/可观测性.py)提供日志、关联身份与脱敏,尚无指标采集接口。两者都按实际实现分别验收,不从旧设计表推定已具备。
|
||||||
|
|
||||||
## 目录速览
|
## 目录速览
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@ -166,13 +170,18 @@ agent-example/
|
|||||||
├── 配置/ # 无凭据配置模板
|
├── 配置/ # 无凭据配置模板
|
||||||
├── web/ # React/TS 作者工作台
|
├── web/ # React/TS 作者工作台
|
||||||
├── 数据库/ # 数据库迁移脚本与旧库迁移工具链
|
├── 数据库/ # 数据库迁移脚本与旧库迁移工具链
|
||||||
|
├── 部署/ # 发布镜像与实例启停模板
|
||||||
|
├── upstream/ # 上游溯源记录
|
||||||
|
├── .github/ # 自动化检查流程
|
||||||
├── .agent/ # 智能体能力中枢(规则、约束、规范、角色合同、技能)
|
├── .agent/ # 智能体能力中枢(规则、约束、规范、角色合同、技能)
|
||||||
├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录
|
├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录
|
||||||
└── data/ # 运行态数据(gitignore)
|
└── data/ # 配置指定的本地工作文件;非 PG 正式内容权威(gitignore)
|
||||||
```
|
```
|
||||||
|
|
||||||
## 验证纪律
|
## 验证纪律
|
||||||
|
|
||||||
|
隔离合成验证只覆盖其执行的协议与业务行为;真实外部模型行为、文学效果、标定和正式启用分别需要对应证据与批准。合成提供方、读取报告或安装成功均不授予生产启用资格。
|
||||||
|
|
||||||
完成 = 机械验证:无自动化绿证据不得声称完成。测试默认离线禁网禁库;空收集、全跳过、超时与缺依赖都如实呈现,不冒充通过。详见 [`.agent/rules/测试隔离.md`](.agent/rules/测试隔离.md),现行身份登记见 [tests/用例清单.json](tests/用例清单.json)。
|
完成 = 机械验证:无自动化绿证据不得声称完成。测试默认离线禁网禁库;空收集、全跳过、超时与缺依赖都如实呈现,不冒充通过。详见 [`.agent/rules/测试隔离.md`](.agent/rules/测试隔离.md),现行身份登记见 [tests/用例清单.json](tests/用例清单.json)。
|
||||||
|
|
||||||
所有任务从 [`AGENTS.md`](AGENTS.md) 开始。
|
所有任务从 [`AGENTS.md`](AGENTS.md) 开始。
|
||||||
@ -205,11 +214,11 @@ CLI 使用 `.venv/bin/python -m muse 审阅 <作者配置> 列表 <章节ID>`
|
|||||||
|
|
||||||
“审校 <作者配置> 案例采集/案例审阅/案例决定/案例撤回 <请求JSON>”调用同一B06/S01链;“案例 <case_id>”与“案例清单”只读查询。采集只形成待复核案例,真实引用的字段与许可见[案例操作合同](.agent/skills/操作/记录机器味案例/references/案例合同.md)。四类跨书确认案例可经“案例规则 <请求JSON>”提出候选,不能直接启用。
|
“审校 <作者配置> 案例采集/案例审阅/案例决定/案例撤回 <请求JSON>”调用同一B06/S01链;“案例 <case_id>”与“案例清单”只读查询。采集只形成待复核案例,真实引用的字段与许可见[案例操作合同](.agent/skills/操作/记录机器味案例/references/案例合同.md)。四类跨书确认案例可经“案例规则 <请求JSON>”提出候选,不能直接启用。
|
||||||
|
|
||||||
“行为评测 <场景JSON> --适配器 scripted --观察 <观察JSON>”核对逐例动作、报告结构和前后文档哈希。角色模式显式提供`--配置 评测.toml --业务配置 合成资料.toml --动作 准备 --目标 请求.json`,固定S02任务并返回可审请求;`--动作 执行/报告 --目标 任务ID`分别点名执行与读回。请求字段见[角色行为命令](src/muse/接入/cli/角色行为命令.py),缺少配置不回退脚本。场景固定输入见[六类合成场景](tests/夹具/行为评测/诊断机器味场景.json),runtime_verified仅证明实际工具协议,合成调用不认证真实外部模型行为。
|
“行为评测 <场景JSON> --适配器 scripted --观察 <观察JSON>”核对逐例动作、报告结构和前后文档哈希。角色模式显式提供`--配置 评测.toml --业务配置 合成资料.toml --动作 准备 --目标 请求.json`,固定S02任务并返回可审请求;`--动作 执行/报告 --目标 任务ID`分别点名执行与读回。请求字段见[角色行为命令](src/muse/接入/cli/角色行为命令.py),缺少配置不回退脚本。场景固定输入见[六类合成场景](tests/夹具/行为评测/诊断机器味场景.json),runtime_verified仅证明实际工具协议;效果边界见[验证纪律](#验证纪律)。
|
||||||
|
|
||||||
## 隔离数据集与实验条件
|
## 隔离数据集与实验条件
|
||||||
|
|
||||||
方法的合格评测证明须先运行`评测 <评测配置> 导出启用凭据 <请求JSON> --维护配置 <同库维护配置>`,请求含experiment_id、approval_ref和valid_until。导出不会启用方法;在方法详情页填写凭据编号,核对已测版本后再确认启用写手用途。停止来源实验或标定会阻断新启用与新消费,历史记录保留。合成验证不作为真实外部模型效果验收。
|
方法的合格评测证明须先运行`评测 <评测配置> 导出启用凭据 <请求JSON> --维护配置 <同库维护配置>`,请求含experiment_id、approval_ref和valid_until。导出不会启用方法;在方法详情页填写凭据编号,核对已测版本后再确认启用写手用途。停止来源实验或标定会阻断新启用与新消费,历史记录保留。评测证据的边界见[验证纪律](#验证纪律)。
|
||||||
|
|
||||||
方法写手对照先运行`评测 <维护配置> 发布方法数据集 <请求JSON>`,由B04读取确切确认版本;用返回的`method_target`创建实验。两臂共享写手模板,只在处理组加入获准方法投影。请求与边界见[B10方法版本对照](docs/系统架构/新版设计/模块设计/B10-效果评测.md#方法版本对照),不改变方法启用状态。
|
方法写手对照先运行`评测 <维护配置> 发布方法数据集 <请求JSON>`,由B04读取确切确认版本;用返回的`method_target`创建实验。两臂共享写手模板,只在处理组加入获准方法投影。请求与边界见[B10方法版本对照](docs/系统架构/新版设计/模块设计/B10-效果评测.md#方法版本对照),不改变方法启用状态。
|
||||||
|
|
||||||
@ -233,7 +242,7 @@ CLI 使用 `.venv/bin/python -m muse 审阅 <作者配置> 列表 <章节ID>`
|
|||||||
|
|
||||||
正文来源对照先用`评测 <维护配置> 发布回放数据集 <请求JSON>`或`POST /api/v1/evaluation/replay-datasets`封存资料。请求按[回放数据集合同](src/muse/效果评测/数据集.py)声明样本、分割、许可引用、批准及到期时间;每例按[B09材料选择](src/muse/上下文/回放材料.py)指定作品、目标章、历史文稿版本和预算。A使用历史正文,B使用卡片索引,C使用索引及关联正文;目标正文只进入答案区。评测账号不能直接读取正式正文表。
|
正文来源对照先用`评测 <维护配置> 发布回放数据集 <请求JSON>`或`POST /api/v1/evaluation/replay-datasets`封存资料。请求按[回放数据集合同](src/muse/效果评测/数据集.py)声明样本、分割、许可引用、批准及到期时间;每例按[B09材料选择](src/muse/上下文/回放材料.py)指定作品、目标章、历史文稿版本和预算。A使用历史正文,B使用卡片索引,C使用索引及关联正文;目标正文只进入答案区。评测账号不能直接读取正式正文表。
|
||||||
|
|
||||||
这类实验使用`kind=code`、`target_ref=B09.writer-replay`,目标版本和内容哈希均指向实际安装构建身份,实验臂为A/B/C。比较覆盖三组文本对,报告以A/C为主,其余两组单列诊断;调用上限需覆盖三次生成及每名评委的三次比较。实验期限不得超过资料批准期限,字段策略变化阻止新派发。真实文学标定和启用判断仍须独立证据,不能把合成提供方或读取报告当作通过。
|
这类实验使用`kind=code`、`target_ref=B09.writer-replay`,目标版本和内容哈希均指向实际安装构建身份,实验臂为A/B/C。比较覆盖三组文本对,报告以A/C为主,其余两组单列诊断;调用上限需覆盖三次生成及每名评委的三次比较。实验期限不得超过资料批准期限,字段策略变化阻止新派发。真实文学标定和启用所需证据见[验证纪律](#验证纪律)。
|
||||||
|
|
||||||
评测实验可用`max_judge_corrections`固定0—2次引文纠正(默认1,实际次数还受原调用配额约束)。工作面和报告保留每次判断拒绝、实际费用及单元纠正上限。若S02已经保存而实验登记中断,恢复会要求先补登原交付;不要通过重建调用替换已发生回合。以评测用途配置运行`muse 评测 <配置.toml> 补登记交付 <请求.json>`,请求包含`unit_id`、`call_id`和原始`output`对象;HTTP对应`POST /api/v1/evaluation/deliveries/restore`。服务核对原S02回合哈希,只在暂停或失败任务补登,原样重放幂等,已停止实验拒绝新增结果。
|
评测实验可用`max_judge_corrections`固定0—2次引文纠正(默认1,实际次数还受原调用配额约束)。工作面和报告保留每次判断拒绝、实际费用及单元纠正上限。若S02已经保存而实验登记中断,恢复会要求先补登原交付;不要通过重建调用替换已发生回合。以评测用途配置运行`muse 评测 <配置.toml> 补登记交付 <请求.json>`,请求包含`unit_id`、`call_id`和原始`output`对象;HTTP对应`POST /api/v1/evaluation/deliveries/restore`。服务核对原S02回合哈希,只在暂停或失败任务补登,原样重放幂等,已停止实验拒绝新增结果。
|
||||||
|
|
||||||
@ -276,7 +285,7 @@ JSON请求按[经验接入合同](src/muse/接入/经验操作.py)填写,规
|
|||||||
|
|
||||||
## 独立发布与系统管理
|
## 独立发布与系统管理
|
||||||
|
|
||||||
`uv run python 工具/构建发布.py --输出 <全新发布目录>` 一次生成接口、工作台、资源清单、wheel、带哈希的运行依赖及部署文件。生成目录不含工作树、测试或私人资料;重用目录会拒绝覆盖。`--已生成` 只用于复用刚构建的资源,仍会核对当前代码哈希。
|
`.venv/bin/python 工具/构建发布.py --输出 <全新发布目录>` 一次生成接口、工作台、资源清单、wheel、带哈希的运行依赖及部署文件。生成目录不含工作树、测试或私人资料;重用目录会拒绝覆盖。`--已生成` 只用于复用刚构建的资源,仍会核对当前代码哈希。
|
||||||
|
|
||||||
发布目录可直接作为 Docker 构建上下文:`docker build --platform linux/amd64 -t <本地标签> <发布目录>`。Dockerfile固定 Python 和 Node 的镜像摘要,Pi 及其依赖由锁文件固定;应用以 UID/GID 10001运行。镜像构建不执行数据库迁移。
|
发布目录可直接作为 Docker 构建上下文:`docker build --platform linux/amd64 -t <本地标签> <发布目录>`。Dockerfile固定 Python 和 Node 的镜像摘要,Pi 及其依赖由锁文件固定;应用以 UID/GID 10001运行。镜像构建不执行数据库迁移。
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@ dependencies = [
|
|||||||
"itsdangerous>=2.2",
|
"itsdangerous>=2.2",
|
||||||
"jsonschema>=4.23",
|
"jsonschema>=4.23",
|
||||||
"referencing>=0.28.4",
|
"referencing>=0.28.4",
|
||||||
"psycopg[binary]>=3.2",
|
"psycopg[binary,pool]>=3.2",
|
||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"pyyaml>=6.0",
|
"pyyaml>=6.0",
|
||||||
"uvicorn>=0.30",
|
"uvicorn>=0.30",
|
||||||
@ -54,14 +54,18 @@ packages = ["src/muse"]
|
|||||||
# 只收集新版用例目录;旧实现与旧测试树已退出本仓,不参与同一收集。
|
# 只收集新版用例目录;旧实现与旧测试树已退出本仓,不参与同一收集。
|
||||||
testpaths = ["tests/单元", "tests/契约", "tests/集成", "tests/架构", "tests/迁移", "tests/端到端", "tests/真实调用"]
|
testpaths = ["tests/单元", "tests/契约", "tests/集成", "tests/架构", "tests/迁移", "tests/端到端", "tests/真实调用"]
|
||||||
# 独立维护脚本以其目录为导入根;不把旧 muse 树加入路径。
|
# 独立维护脚本以其目录为导入根;不把旧 muse 树加入路径。
|
||||||
pythonpath = ["数据库/旧库迁移"]
|
pythonpath = ["src", "工具", "数据库/旧库迁移"]
|
||||||
# 架构/契约/集成/单元 是目录级组织;完整用例身份绑定登记文件与符号。
|
# 架构/契约/集成/单元 是目录级组织;完整用例身份绑定登记文件与符号。
|
||||||
addopts = "--strict-markers --timeout=120"
|
addopts = "--strict-markers --timeout=120"
|
||||||
markers = [
|
markers = [
|
||||||
|
"安装包: 实际wheel/发布资源验收,不属于无包开发层",
|
||||||
|
"case_id(id): 源码中的稳定用例身份与人工语义",
|
||||||
"数据库: 显式外部执行需要 MUSE_TEST_DATABASE_URL;缺配置失败",
|
"数据库: 显式外部执行需要 MUSE_TEST_DATABASE_URL;缺配置失败",
|
||||||
"网络: 需要真实网络访问;必须显式标记",
|
"网络: 需要真实网络访问;必须显式标记",
|
||||||
"真实模型: 需要真实模型调用与预算授权;必须显式标记",
|
"真实模型: 需要真实模型调用与预算授权;必须显式标记",
|
||||||
"浏览器: 需要真实浏览器与后端;必须显式标记",
|
"浏览器: 需要真实浏览器与后端;必须显式标记",
|
||||||
|
"宿主: 需要固定真实宿主与隔离数据库;必须显式选择",
|
||||||
|
"慢: 单例超过三分钟的真实链用例;发现期默认排除,最终全量才跑",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
@ -76,6 +80,6 @@ select = ["E", "F", "W", "I", "B", "UP"]
|
|||||||
venvPath = "."
|
venvPath = "."
|
||||||
venv = ".venv"
|
venv = ".venv"
|
||||||
include = ["src", "工具", "数据库/旧库迁移"]
|
include = ["src", "工具", "数据库/旧库迁移"]
|
||||||
extraPaths = ["数据库/旧库迁移"]
|
extraPaths = ["src", "工具", "数据库/旧库迁移"]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
typeCheckingMode = "standard"
|
typeCheckingMode = "standard"
|
||||||
|
|||||||
19
uv.lock
generated
19
uv.lock
generated
@ -185,7 +185,7 @@ dependencies = [
|
|||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "itsdangerous" },
|
{ name = "itsdangerous" },
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary", "pool"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "referencing" },
|
{ name = "referencing" },
|
||||||
@ -206,7 +206,7 @@ requires-dist = [
|
|||||||
{ name = "httpx", specifier = ">=0.28" },
|
{ name = "httpx", specifier = ">=0.28" },
|
||||||
{ name = "itsdangerous", specifier = ">=2.2" },
|
{ name = "itsdangerous", specifier = ">=2.2" },
|
||||||
{ name = "jsonschema", specifier = ">=4.23" },
|
{ name = "jsonschema", specifier = ">=4.23" },
|
||||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
|
{ name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0" },
|
{ name = "pyyaml", specifier = ">=6.0" },
|
||||||
{ name = "referencing", specifier = ">=0.28.4" },
|
{ name = "referencing", specifier = ">=0.28.4" },
|
||||||
@ -265,6 +265,9 @@ wheels = [
|
|||||||
binary = [
|
binary = [
|
||||||
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
|
||||||
]
|
]
|
||||||
|
pool = [
|
||||||
|
{ name = "psycopg-pool" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "psycopg-binary"
|
name = "psycopg-binary"
|
||||||
@ -306,6 +309,18 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "psycopg-pool"
|
||||||
|
version = "3.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.5"
|
version = "2.13.5"
|
||||||
|
|||||||
185
工具/前端用例身份.mjs
Normal file
185
工具/前端用例身份.mjs
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/** 前端用例身份与人工合同驻留测试源码,当前位置由 TypeScript AST 派生。 */
|
||||||
|
import { readFileSync, readdirSync } from "node:fs";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { resolve, relative, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const 位置字段 = new Set([
|
||||||
|
"file",
|
||||||
|
"symbol",
|
||||||
|
"line",
|
||||||
|
"parameter_ids",
|
||||||
|
"node_ids",
|
||||||
|
"fixtures",
|
||||||
|
"markers",
|
||||||
|
]);
|
||||||
|
const 标签 = "@muse-case ";
|
||||||
|
|
||||||
|
export function 分析文件(root, file) {
|
||||||
|
const ts = createRequire(join(resolve(root), "web/package.json"))(
|
||||||
|
"typescript",
|
||||||
|
);
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
||||||
|
if (source.parseDiagnostics.length)
|
||||||
|
throw new Error(`TypeScript 解析失败:${file}`);
|
||||||
|
const testers = new Set();
|
||||||
|
for (const node of source.statements) {
|
||||||
|
if (
|
||||||
|
!ts.isImportDeclaration(node) ||
|
||||||
|
!["vitest", "@playwright/test"].includes(node.moduleSpecifier.text)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const bindings = node.importClause?.namedBindings;
|
||||||
|
if (bindings && ts.isNamedImports(bindings)) {
|
||||||
|
for (const item of bindings.elements) {
|
||||||
|
if (["test", "it"].includes((item.propertyName ?? item.name).text))
|
||||||
|
testers.add(item.name.text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const declarations = [];
|
||||||
|
const metadataComments = new Set();
|
||||||
|
function visit(node) {
|
||||||
|
for (const comment of [
|
||||||
|
...(ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []),
|
||||||
|
...(ts.getTrailingCommentRanges(text, node.end) ?? []),
|
||||||
|
]) {
|
||||||
|
if (text.slice(comment.pos, comment.end).includes(标签))
|
||||||
|
metadataComments.add(comment.pos);
|
||||||
|
}
|
||||||
|
if (ts.isCallExpression(node)) {
|
||||||
|
let callee = node.expression;
|
||||||
|
const modifiers = [];
|
||||||
|
while (ts.isPropertyAccessExpression(callee)) {
|
||||||
|
modifiers.push(callee.name.text);
|
||||||
|
callee = callee.expression;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
ts.isIdentifier(callee) &&
|
||||||
|
testers.has(callee.text) &&
|
||||||
|
modifiers.every((m) =>
|
||||||
|
[
|
||||||
|
"only",
|
||||||
|
"skip",
|
||||||
|
"todo",
|
||||||
|
"fails",
|
||||||
|
"concurrent",
|
||||||
|
"sequential",
|
||||||
|
"fixme",
|
||||||
|
].includes(m),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
const title = node.arguments[0];
|
||||||
|
if (!title || !ts.isStringLiteralLike(title))
|
||||||
|
throw new Error(
|
||||||
|
`测试标题必须是静态完整身份:${file}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}`,
|
||||||
|
);
|
||||||
|
const start = node.getStart(source);
|
||||||
|
const comments =
|
||||||
|
ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [];
|
||||||
|
const adjacent = comments.filter((c) =>
|
||||||
|
text.slice(c.pos, c.end).includes(标签),
|
||||||
|
);
|
||||||
|
if (adjacent.length > 1)
|
||||||
|
throw new Error(`同一测试出现多个身份:${file}:${title.text}`);
|
||||||
|
let metadata = null;
|
||||||
|
let comment = null;
|
||||||
|
if (adjacent.length) {
|
||||||
|
comment = adjacent[0];
|
||||||
|
const raw = text.slice(comment.pos, comment.end);
|
||||||
|
if (
|
||||||
|
!raw.startsWith(`// ${标签}`) ||
|
||||||
|
text.slice(comment.end, start).trim()
|
||||||
|
)
|
||||||
|
throw new Error(`用例元信息须紧邻测试声明:${file}:${title.text}`);
|
||||||
|
metadata = JSON.parse(raw.slice(3 + 标签.length));
|
||||||
|
}
|
||||||
|
declarations.push({
|
||||||
|
title: title.text,
|
||||||
|
start,
|
||||||
|
titleStart: title.getStart(source),
|
||||||
|
titleEnd: title.end,
|
||||||
|
comment,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// each 的展开须显式赋予各行独立身份,不能静默漏收。
|
||||||
|
if (
|
||||||
|
ts.isPropertyAccessExpression(node.expression) &&
|
||||||
|
node.expression.name.text === "each"
|
||||||
|
) {
|
||||||
|
let owner = node.expression.expression;
|
||||||
|
while (ts.isPropertyAccessExpression(owner)) owner = owner.expression;
|
||||||
|
if (ts.isIdentifier(owner) && testers.has(owner.text))
|
||||||
|
throw new Error(`参数化测试请逐行声明稳定身份:${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ts.forEachChild(node, visit);
|
||||||
|
}
|
||||||
|
visit(source);
|
||||||
|
if (!declarations.length)
|
||||||
|
throw new Error(`测试文件没有可收集的具名测试:${file}`);
|
||||||
|
if (
|
||||||
|
metadataComments.size !==
|
||||||
|
declarations.filter((d) => d.metadata !== null).length
|
||||||
|
)
|
||||||
|
throw new Error(`存在未绑定测试的用例元信息:${file}`);
|
||||||
|
return { text, declarations };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 测试文件(root) {
|
||||||
|
const result = [];
|
||||||
|
function walk(dir) {
|
||||||
|
for (const item of readdirSync(dir, { withFileTypes: true })) {
|
||||||
|
if (item.isDirectory()) walk(join(dir, item.name));
|
||||||
|
else if (/\.(test|spec)\.[jt]sx?$/.test(item.name))
|
||||||
|
result.push(join(dir, item.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
walk(join(root, "web/tests"));
|
||||||
|
walk(join(root, "web/src"));
|
||||||
|
return result.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function 收集前端用例(root) {
|
||||||
|
root = resolve(root);
|
||||||
|
const seen = new Set();
|
||||||
|
const result = [];
|
||||||
|
for (const file of 测试文件(root)) {
|
||||||
|
for (const { title, metadata } of 分析文件(root, file).declarations) {
|
||||||
|
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
|
||||||
|
throw new Error(`缺失用例元信息:${file}:${title}`);
|
||||||
|
const id = metadata.case_id;
|
||||||
|
if (typeof id !== "string" || !/^(?:TC|NC|O\d+)-[^\s::]+$/.test(id))
|
||||||
|
throw new Error(`无效完整身份:${file}:${id}`);
|
||||||
|
if (!title.startsWith(`${id}:`))
|
||||||
|
throw new Error(`元信息与测试身份漂移:${file}:${id} / ${title}`);
|
||||||
|
if (seen.has(id)) throw new Error(`重复用例身份:${id}`);
|
||||||
|
seen.add(id);
|
||||||
|
for (const key of Object.keys(metadata))
|
||||||
|
if (位置字段.has(key))
|
||||||
|
throw new Error(`元信息不得登记派生位置 ${key}:${id}`);
|
||||||
|
result.push({
|
||||||
|
...metadata,
|
||||||
|
file: relative(root, file).replaceAll("\\", "/"),
|
||||||
|
symbol: title,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result.sort((a, b) => a.case_id.localeCompare(b.case_id, "en"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.argv[1] &&
|
||||||
|
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
process.stdout.write(
|
||||||
|
`${JSON.stringify(收集前端用例(process.argv[2] ?? process.cwd()), null, 2)}\n`,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(`${error.message}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
120
工具/并行数据库测试.py
Normal file
120
工具/并行数据库测试.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""隔离库层的分片并行入口:发现与修复期用分片,全量只跑一次做最终判定。
|
||||||
|
|
||||||
|
为什么能并行:每个 pytest 会话自带会话前缀的克隆库(`muse_test_<会话>_<随机>`),
|
||||||
|
角色初始化走共享目录的咨询锁,模板按指纹缓存——互不共享可写状态。
|
||||||
|
分片只按测试文件切,避免同一文件的夹具被拆到两个进程。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python 工具/并行数据库测试.py --片数 3 # 全部隔离库用例
|
||||||
|
python 工具/并行数据库测试.py --片数 3 tests/迁移 # 只跑指定范围
|
||||||
|
退出码:任一分片非零即非零;每片日志落在 /tmp/muse-数据库分片-<序号>.log。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
根 = Path(__file__).resolve().parents[1]
|
||||||
|
基础表达式 = "数据库 and not 宿主 and not 真实模型 and not 浏览器 and not 网络"
|
||||||
|
|
||||||
|
|
||||||
|
def 收集分片范围(范围: list[str], 片数: int, 表达式: str) -> list[list[str]]:
|
||||||
|
"""按文件把用例均分到各片;同一文件的用例永不拆开。"""
|
||||||
|
|
||||||
|
命令 = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"--外部环境",
|
||||||
|
"-m",
|
||||||
|
表达式,
|
||||||
|
"--collect-only",
|
||||||
|
"-q",
|
||||||
|
*范围,
|
||||||
|
]
|
||||||
|
结果 = subprocess.run(命令, cwd=根, capture_output=True, text=True, timeout=600)
|
||||||
|
if 结果.returncode != 0:
|
||||||
|
print(结果.stdout[-2000:], file=sys.stderr)
|
||||||
|
print(结果.stderr[-2000:], file=sys.stderr)
|
||||||
|
raise SystemExit("分片前采集失败;先修采集错误再分片")
|
||||||
|
计数: dict[str, int] = {}
|
||||||
|
for 行 in 结果.stdout.splitlines():
|
||||||
|
if "::" not in 行:
|
||||||
|
continue
|
||||||
|
文件 = 行.split("::", 1)[0].strip()
|
||||||
|
if 文件.endswith(".py"):
|
||||||
|
计数[文件] = 计数.get(文件, 0) + 1
|
||||||
|
if not 计数:
|
||||||
|
raise SystemExit("没有采集到隔离库用例;检查标记表达式与范围")
|
||||||
|
桶: list[list[str]] = [[] for _ in range(片数)]
|
||||||
|
负载 = [0] * 片数
|
||||||
|
for 文件, 数量 in sorted(计数.items(), key=lambda kv: (-kv[1], kv[0])):
|
||||||
|
最轻 = 负载.index(min(负载))
|
||||||
|
桶[最轻].append(文件)
|
||||||
|
负载[最轻] += 数量
|
||||||
|
return 桶
|
||||||
|
|
||||||
|
|
||||||
|
def 跑一片(序号: int, 文件: list[str], 表达式: str) -> tuple[int, str, str]:
|
||||||
|
日志 = Path(tempfile.gettempdir()) / f"muse-数据库分片-{序号}.log"
|
||||||
|
命令 = [
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"--外部环境",
|
||||||
|
"-m",
|
||||||
|
表达式,
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"no:cacheprovider",
|
||||||
|
"--durations=10",
|
||||||
|
*文件,
|
||||||
|
]
|
||||||
|
with 日志.open("w", encoding="utf-8") as 输出:
|
||||||
|
输出.write(" ".join(shlex.quote(x) for x in 命令) + "\n\n")
|
||||||
|
输出.flush()
|
||||||
|
进程 = subprocess.run(命令, cwd=根, stdout=输出, stderr=subprocess.STDOUT)
|
||||||
|
尾部 = "\n".join(日志.read_text(encoding="utf-8").splitlines()[-3:])
|
||||||
|
return 进程.returncode, str(日志), 尾部
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
解析 = argparse.ArgumentParser(description=__doc__)
|
||||||
|
解析.add_argument("--片数", type=int, default=3)
|
||||||
|
解析.add_argument(
|
||||||
|
"--含慢", action="store_true", help="含单例超过三分钟的慢用例;最终全量才需要"
|
||||||
|
)
|
||||||
|
解析.add_argument("范围", nargs="*", default=[])
|
||||||
|
值 = 解析.parse_args(argv)
|
||||||
|
if 值.片数 < 1:
|
||||||
|
raise SystemExit("片数必须为正整数")
|
||||||
|
# 连接串先查:采集期不连库,缺配置只会在采集之后暴露成误导性的「采集失败」。
|
||||||
|
环境 = {**os.environ}
|
||||||
|
if not 环境.get("MUSE_TEST_DATABASE_URL"):
|
||||||
|
print("缺少 MUSE_TEST_DATABASE_URL;隔离库层拒绝隐式回退", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
范围 = 值.范围 or ["tests"]
|
||||||
|
表达式 = 基础表达式 if 值.含慢 else f"{基础表达式} and not 慢"
|
||||||
|
桶 = 收集分片范围(范围, 值.片数, 表达式)
|
||||||
|
print(f"分片:{值.片数} 片,共 {sum(len(b) for b in 桶)} 个测试文件;表达式 {表达式}")
|
||||||
|
for 序号, 文件 in enumerate(桶, start=1):
|
||||||
|
print(f" 第 {序号} 片:{len(文件)} 个文件")
|
||||||
|
with ThreadPoolExecutor(max_workers=值.片数) as 池:
|
||||||
|
结果 = list(池.map(lambda 项: 跑一片(项[0], 项[1], 表达式), enumerate(桶, start=1)))
|
||||||
|
失败 = 0
|
||||||
|
for 序号, (码, 日志, 尾部) in enumerate(结果, start=1):
|
||||||
|
print(f"\n=== 第 {序号} 片 退出码 {码};日志 {日志}\n{尾部}")
|
||||||
|
失败 += 码 != 0
|
||||||
|
print(f"\n分片汇总:{值.片数} 片,{失败} 片非零")
|
||||||
|
return 1 if 失败 else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
131
工具/效果依赖.py
Normal file
131
工具/效果依赖.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""从实际业务入口递归提取本仓 Python 导入闭包,用于效果资质投影。
|
||||||
|
|
||||||
|
入口是能力合同,不是待维护的文件白名单。未知动态导入拒绝封闭;类型专用导入
|
||||||
|
不参与运行闭包。资源由构建器按生成阶段传入,不按文件名猜测是否影响效果。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import hashlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
能力入口 = {
|
||||||
|
"writer_generation": ["muse.编排.生成正文", "muse.编排.执行评测"],
|
||||||
|
"literary_judging": [
|
||||||
|
"muse.编排.执行评测",
|
||||||
|
"muse.效果评测.文学评分",
|
||||||
|
"muse.效果评测.启用判据",
|
||||||
|
],
|
||||||
|
"semantic_detection": [
|
||||||
|
"muse.编排.审校修订",
|
||||||
|
"muse.编排.执行评测",
|
||||||
|
"muse.效果评测.语义检测",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _运行节点(树: ast.AST):
|
||||||
|
"""保留函数内部实际导入;跳过静态类型检查分支。"""
|
||||||
|
yield 树
|
||||||
|
for 子 in ast.iter_child_nodes(树):
|
||||||
|
if isinstance(子, ast.If) and (
|
||||||
|
isinstance(子.test, ast.Name)
|
||||||
|
and 子.test.id == "TYPE_CHECKING"
|
||||||
|
or isinstance(子.test, ast.Attribute)
|
||||||
|
and 子.test.attr == "TYPE_CHECKING"
|
||||||
|
):
|
||||||
|
for 节点 in 子.orelse:
|
||||||
|
yield from _运行节点(节点)
|
||||||
|
else:
|
||||||
|
yield from _运行节点(子)
|
||||||
|
|
||||||
|
|
||||||
|
def _闭包(源码根: Path, 入口: list[str]) -> tuple[dict[str, str], list[str]]:
|
||||||
|
模块文件 = {}
|
||||||
|
for 文件 in 源码根.rglob("*.py"):
|
||||||
|
if "__pycache__" in 文件.parts:
|
||||||
|
continue
|
||||||
|
片段 = list(文件.relative_to(源码根).with_suffix("").parts)
|
||||||
|
if 片段[-1] == "__init__":
|
||||||
|
片段.pop()
|
||||||
|
模块文件[".".join(片段)] = 文件
|
||||||
|
结果: dict[str, str] = {}
|
||||||
|
未封闭: set[str] = set()
|
||||||
|
待查 = list(入口)
|
||||||
|
while 待查:
|
||||||
|
名 = 待查.pop()
|
||||||
|
if 名 in 结果:
|
||||||
|
continue
|
||||||
|
文件 = 模块文件.get(名)
|
||||||
|
if 文件 is None:
|
||||||
|
未封闭.add(f"缺本仓模块:{名}")
|
||||||
|
continue
|
||||||
|
树 = ast.parse(文件.read_text(encoding="utf-8"))
|
||||||
|
结果[名] = hashlib.sha256(ast.dump(树, include_attributes=False).encode()).hexdigest()
|
||||||
|
依赖: set[str] = set()
|
||||||
|
节点们 = list(_运行节点(树))
|
||||||
|
动态名称 = {"__import__"}
|
||||||
|
for 节点 in 节点们:
|
||||||
|
if isinstance(节点, ast.ImportFrom) and 节点.module == "importlib":
|
||||||
|
动态名称.update(v.asname or v.name for v in 节点.names if v.name == "import_module")
|
||||||
|
for 节点 in 节点们:
|
||||||
|
if isinstance(节点, ast.Import):
|
||||||
|
依赖.update(v.name for v in 节点.names)
|
||||||
|
elif isinstance(节点, ast.ImportFrom):
|
||||||
|
基 = 节点.module or ""
|
||||||
|
if 节点.level:
|
||||||
|
包 = 名.split(".") if 文件.name == "__init__.py" else 名.split(".")[:-1]
|
||||||
|
基 = ".".join(包[: len(包) - 节点.level + 1] + ([基] if 基 else []))
|
||||||
|
依赖.add(基)
|
||||||
|
依赖.update(f"{基}.{v.name}" for v in 节点.names if f"{基}.{v.name}" in 模块文件)
|
||||||
|
elif isinstance(节点, ast.Call):
|
||||||
|
函数 = 节点.func
|
||||||
|
动态 = isinstance(函数, ast.Name) and 函数.id in 动态名称
|
||||||
|
动态 = (
|
||||||
|
动态
|
||||||
|
or isinstance(函数, ast.Attribute)
|
||||||
|
and 函数.attr in {"import_module", "__import__"}
|
||||||
|
)
|
||||||
|
if 动态:
|
||||||
|
if (
|
||||||
|
节点.args
|
||||||
|
and isinstance(节点.args[0], ast.Constant)
|
||||||
|
and isinstance(节点.args[0].value, str)
|
||||||
|
and not 节点.args[0].value.startswith(".")
|
||||||
|
):
|
||||||
|
依赖.add(节点.args[0].value)
|
||||||
|
else:
|
||||||
|
未封闭.add(f"动态导入:{名}:{节点.lineno}")
|
||||||
|
if isinstance(函数, ast.Name) and 函数.id in {"exec", "eval"}:
|
||||||
|
未封闭.add(f"动态代码:{名}:{节点.lineno}")
|
||||||
|
# 导入子模块也会执行各级包的 __init__。
|
||||||
|
依赖.update(".".join(名.split(".")[:i]) for i in range(1, len(名.split("."))))
|
||||||
|
for 目标 in sorted(依赖):
|
||||||
|
if 目标 == "muse" or 目标.startswith("muse."):
|
||||||
|
if 目标 in 模块文件:
|
||||||
|
if 目标 not in 结果:
|
||||||
|
待查.append(目标)
|
||||||
|
else:
|
||||||
|
未封闭.add(f"缺本仓模块:{目标}")
|
||||||
|
return dict(sorted(结果.items())), sorted(未封闭)
|
||||||
|
|
||||||
|
|
||||||
|
def 构建效果依赖(根: Path, 资源: dict[str, str], 分组: dict[str, list[str]]) -> dict:
|
||||||
|
能力 = {}
|
||||||
|
for 名, 根模块 in 能力入口.items():
|
||||||
|
入口 = sorted(set(根模块 + ["muse.编排.运行装配"]))
|
||||||
|
代码, 未封闭 = _闭包(根 / "src", 入口)
|
||||||
|
能力[名] = {
|
||||||
|
"roots": 入口,
|
||||||
|
"code": 代码,
|
||||||
|
"resources": dict(sorted(资源.items())),
|
||||||
|
"resource_groups": 分组,
|
||||||
|
"closed": not 未封闭,
|
||||||
|
"unresolved": 未封闭,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"version": "effect-dependencies-v1",
|
||||||
|
"dependency_lock_sha256": hashlib.sha256((根 / "uv.lock").read_bytes()).hexdigest(),
|
||||||
|
"capabilities": 能力,
|
||||||
|
}
|
||||||
47
工具/构建发布.py
47
工具/构建发布.py
@ -3,55 +3,51 @@
|
|||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zipfile import ZipFile
|
from zipfile import ZipFile
|
||||||
|
|
||||||
|
from 构建编排 import 执行构建
|
||||||
|
|
||||||
根 = Path(__file__).resolve().parents[1]
|
根 = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def 运行(args):
|
def 运行(args):
|
||||||
subprocess.run(args, cwd=根, check=True)
|
subprocess.run(args, cwd=根, env={**os.environ, "PYTHONPATH": str(根 / "src")}, check=True)
|
||||||
|
|
||||||
|
|
||||||
def 构建发布(output: Path, *, 生成=True):
|
def 构建发布(output: Path, *, 生成=True):
|
||||||
output = output.resolve()
|
output = output.resolve()
|
||||||
if output.exists():
|
if output.exists():
|
||||||
raise ValueError("发布目录已存在;使用新目录保留已有版本")
|
raise ValueError("发布目录已存在;使用新目录保留已有版本")
|
||||||
output.parent.mkdir(parents=True, exist_ok=True)
|
# 生成参数为旧调用者保留;共享入口始终按内容和产物存在性判断复用。
|
||||||
if 生成:
|
报告 = 执行构建(轮子=True)
|
||||||
运行(["uv", "run", "python", "工具/生成接口.py"])
|
路径 = 报告.get("wheel")
|
||||||
运行(
|
if not isinstance(路径, str):
|
||||||
[
|
raise ValueError("增量构建未返回本批轮子")
|
||||||
"pnpm",
|
wheel = Path(路径).resolve()
|
||||||
"--dir",
|
if not wheel.is_relative_to(根.resolve()) or wheel.suffix != ".whl" or not wheel.is_file():
|
||||||
"web",
|
raise ValueError("本批轮子不存在或不在仓库构建范围内")
|
||||||
"exec",
|
运行([sys.executable, "工具/维护索引.py", "--检查"])
|
||||||
"openapi-typescript",
|
|
||||||
"../docs/接口契约/生成/openapi.generated.json",
|
|
||||||
"-o",
|
|
||||||
"src/接口/生成/类型.ts",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
运行(["pnpm", "--dir", "web", "run", "build"])
|
|
||||||
运行(["uv", "run", "python", "工具/构建资源包.py"])
|
|
||||||
运行(["uv", "run", "python", "工具/维护索引.py", "--检查"])
|
|
||||||
运行(
|
运行(
|
||||||
[
|
[
|
||||||
"uv",
|
sys.executable,
|
||||||
"run",
|
|
||||||
"python",
|
|
||||||
"-c",
|
"-c",
|
||||||
"from muse.资源加载 import 核对资源; "
|
"from muse.资源加载 import 核对资源; "
|
||||||
"from muse.基础设施.系统运行 import 运行代码哈希; "
|
"from muse.基础设施.系统运行 import 运行代码哈希; "
|
||||||
"assert 核对资源()['代码哈希'] == 运行代码哈希(), '资源与当前代码不匹配,先重新构建'",
|
"assert 核对资源()['代码哈希'] == 运行代码哈希(), '资源与当前代码不匹配,先重新构建'",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with tempfile.TemporaryDirectory(prefix=".muse-release-", dir=output.parent) as temporary:
|
with tempfile.TemporaryDirectory(prefix=".muse-release-", dir=output.parent) as temporary:
|
||||||
stage = Path(temporary)
|
stage = Path(temporary)
|
||||||
运行(["uv", "build", "--wheel", "--out-dir", str(stage / "wheels")])
|
(stage / "wheels").mkdir()
|
||||||
|
本批轮子 = stage / "wheels" / wheel.name
|
||||||
|
shutil.copyfile(wheel, 本批轮子)
|
||||||
运行(
|
运行(
|
||||||
[
|
[
|
||||||
"uv",
|
"uv",
|
||||||
@ -129,6 +125,9 @@ def 构建发布(output: Path, *, 生成=True):
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("--输出", required=True, type=Path)
|
parser.add_argument("--输出", required=True, type=Path)
|
||||||
parser.add_argument("--已生成", action="store_true", help="复用刚通过构建的资源,仍核对索引")
|
parser.add_argument(
|
||||||
|
"--已生成", action="store_true", help="兼容参数;统一增量入口自动复用产物,仍校验资源和索引"
|
||||||
|
)
|
||||||
|
parser.add_argument("--缓存根", type=Path, help="显式发布缓存根;不接管普通目录或备份")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
print(json.dumps(构建发布(args.输出, 生成=not args.已生成), ensure_ascii=False))
|
print(json.dumps(构建发布(args.输出, 生成=not args.已生成), ensure_ascii=False))
|
||||||
|
|||||||
372
工具/构建编排.py
Normal file
372
工具/构建编排.py
Normal file
@ -0,0 +1,372 @@
|
|||||||
|
"""按内容推进生成与 wheel 构建;失败时不激活本轮中间结果。
|
||||||
|
|
||||||
|
缓存可随时删除。输入只来自源码、登记资源与构建配置,不扫描作品或运行数据。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
根 = Path(__file__).resolve().parents[1]
|
||||||
|
缓存 = 根 / "构建缓存"
|
||||||
|
接口 = "docs/接口契约/生成/openapi.generated.json"
|
||||||
|
客户端 = "web/src/接口/生成/客户端.ts"
|
||||||
|
类型 = "web/src/接口/生成/类型.ts"
|
||||||
|
|
||||||
|
|
||||||
|
def 文件摘要(路径: Path) -> str:
|
||||||
|
with 路径.open("rb") as 文件:
|
||||||
|
return hashlib.file_digest(文件, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def 路径摘要(目录: Path, 路径们: list[str]) -> dict[str, str]:
|
||||||
|
结果 = {}
|
||||||
|
for 相对 in sorted(set(路径们)):
|
||||||
|
路径 = 目录 / 相对
|
||||||
|
文件们 = sorted(路径.rglob("*")) if 路径.is_dir() else [路径]
|
||||||
|
for 文件 in 文件们:
|
||||||
|
if 文件.is_file() and "__pycache__" not in 文件.parts:
|
||||||
|
结果[文件.relative_to(目录).as_posix()] = 文件摘要(文件)
|
||||||
|
return 结果
|
||||||
|
|
||||||
|
|
||||||
|
def 摘要(值: Any) -> str:
|
||||||
|
return hashlib.sha256(json.dumps(值, sort_keys=True).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def 原子写(路径: Path, 值: Any) -> None:
|
||||||
|
临时 = 路径.with_suffix(".tmp")
|
||||||
|
临时.write_text(json.dumps(值, ensure_ascii=False, indent=2) + "\n")
|
||||||
|
临时.replace(路径)
|
||||||
|
|
||||||
|
|
||||||
|
def 生产前端输入() -> list[str]:
|
||||||
|
"""当前 Vite HTML 入口及本地导入决定生产输入,tsc 测试检查由前端检查承担。
|
||||||
|
|
||||||
|
src 入口目录整体保守纳入,目录外的本地导入递归跟随。自定义解析、非字面量
|
||||||
|
动态导入或 glob 无法静态封闭时退回全 web 输入,不能漏算后悄悄缓存。
|
||||||
|
"""
|
||||||
|
web = 根 / "web"
|
||||||
|
所有 = {
|
||||||
|
p
|
||||||
|
for p in web.rglob("*")
|
||||||
|
if p.is_file()
|
||||||
|
and not {"node_modules", "dist", "test-results", "playwright-report"}.intersection(
|
||||||
|
p.relative_to(web).parts
|
||||||
|
)
|
||||||
|
and not p.name.endswith(".tsbuildinfo")
|
||||||
|
}
|
||||||
|
|
||||||
|
def 保守输入() -> list[str]:
|
||||||
|
return sorted(p.relative_to(根).as_posix() for p in 所有)
|
||||||
|
|
||||||
|
配置 = web / "vite.config.ts"
|
||||||
|
配置文本 = 配置.read_text()
|
||||||
|
# 这些配置可以改变入口/解析/复制边界;未知配置仍走保守闭包。
|
||||||
|
if re.search(r"\b(root|publicDir|rollupOptions|resolve|assetsInclude|lib)\s*:", 配置文本):
|
||||||
|
return 保守输入()
|
||||||
|
配置导入 = re.findall(r"(?:from\s*|import\s*)[\"']([^\"']+)[\"']", 配置文本)
|
||||||
|
if any(v not in {"vite", "@vitejs/plugin-react"} for v in 配置导入):
|
||||||
|
return 保守输入()
|
||||||
|
待查 = {
|
||||||
|
web / n
|
||||||
|
for n in ("index.html", "vite.config.ts", "tsconfig.json", "package.json", "pnpm-lock.yaml")
|
||||||
|
}
|
||||||
|
html = (web / "index.html").read_text()
|
||||||
|
for 入口 in re.findall(r"(?:src|href)=[\"']([^\"']+)[\"']", html):
|
||||||
|
if ":" in 入口 or 入口.startswith("#"):
|
||||||
|
continue
|
||||||
|
文件 = web / 入口.lstrip("/")
|
||||||
|
if not 文件.is_file():
|
||||||
|
return 保守输入()
|
||||||
|
# 入口所属源码目录含声明文件/隐式CSS依赖,整体保守纳入。
|
||||||
|
入口目录 = web / 文件.relative_to(web).parts[0]
|
||||||
|
待查.update(p for p in 所有 if p.is_relative_to(入口目录))
|
||||||
|
待查.add(文件)
|
||||||
|
待查.update(p for p in 所有 if p.is_relative_to(web / "public") or p.name.startswith(".env"))
|
||||||
|
已查: set[Path] = set()
|
||||||
|
导入式 = (
|
||||||
|
r"(?:from\s*|import\s*(?:\(\s*)?|require\s*\(\s*|@import\s*|url\(\s*)[\"']([^\"']+)[\"']"
|
||||||
|
)
|
||||||
|
后缀 = (
|
||||||
|
"",
|
||||||
|
".ts",
|
||||||
|
".tsx",
|
||||||
|
".js",
|
||||||
|
".jsx",
|
||||||
|
".json",
|
||||||
|
".css",
|
||||||
|
"/index.ts",
|
||||||
|
"/index.tsx",
|
||||||
|
"/index.js",
|
||||||
|
)
|
||||||
|
while 待查:
|
||||||
|
文件 = 待查.pop()
|
||||||
|
if 文件 in 已查 or not 文件.is_file():
|
||||||
|
continue
|
||||||
|
已查.add(文件)
|
||||||
|
if 文件.suffix not in {".ts", ".tsx", ".js", ".jsx", ".css"}:
|
||||||
|
continue
|
||||||
|
文本 = 文件.read_text()
|
||||||
|
if re.search(r"import\.meta\.glob|(?:import|require)\s*\(\s*(?![\s\"'])", 文本):
|
||||||
|
return 保守输入()
|
||||||
|
for 名 in re.findall(导入式, 文本):
|
||||||
|
if not 名.startswith((".", "/")):
|
||||||
|
continue
|
||||||
|
路径 = web / 名.lstrip("/") if 名.startswith("/") else 文件.parent / 名
|
||||||
|
路径 = 路径.resolve()
|
||||||
|
if not 路径.is_relative_to(web):
|
||||||
|
raise ValueError("生产前端导入越出 web;请先显式登记跨目录构建输入")
|
||||||
|
候选 = [Path(str(路径).split("?", 1)[0] + 后) for 后 in 后缀]
|
||||||
|
实际 = next((p for p in 候选 if p.is_file()), None)
|
||||||
|
if 实际 is None:
|
||||||
|
if 根 / 类型 in 候选 or 根 / 客户端 in 候选:
|
||||||
|
continue
|
||||||
|
return 保守输入()
|
||||||
|
if 实际 not in 已查:
|
||||||
|
待查.add(实际)
|
||||||
|
return sorted(p.relative_to(根).as_posix() for p in 已查)
|
||||||
|
|
||||||
|
|
||||||
|
def 源清单() -> list[str]:
|
||||||
|
登记 = json.loads((根 / "工具/资源登记.json").read_text())
|
||||||
|
列表 = ["pyproject.toml", "uv.lock", "README.md"]
|
||||||
|
列表 += [
|
||||||
|
"工具/生成接口.py",
|
||||||
|
"工具/生成输入.json",
|
||||||
|
"工具/构建资源包.py",
|
||||||
|
"工具/效果依赖.py",
|
||||||
|
"工具/资源登记.json",
|
||||||
|
"工具/构建编排.py",
|
||||||
|
]
|
||||||
|
列表 += [
|
||||||
|
p.relative_to(根).as_posix()
|
||||||
|
for p in (根 / "src/muse").rglob("*")
|
||||||
|
if p.is_file()
|
||||||
|
and not {"资源", "__pycache__"}.intersection(p.relative_to(根 / "src/muse").parts)
|
||||||
|
]
|
||||||
|
列表 += 生产前端输入()
|
||||||
|
列表 += [v["源路径"] for v in 登记["资源"]] + 登记.get("能力文件", [])
|
||||||
|
列表 += [p.relative_to(根).as_posix() for p in (根 / 登记["迁移目录"]).glob("*.sql")]
|
||||||
|
for 相对 in 列表:
|
||||||
|
路径 = 根 / 相对
|
||||||
|
if not 路径.resolve().is_relative_to(根) or not 路径.is_file():
|
||||||
|
raise ValueError(f"构建输入必须是仓内文件:{相对}")
|
||||||
|
return sorted(set(列表))
|
||||||
|
|
||||||
|
|
||||||
|
def 构建(轮子: bool) -> dict:
|
||||||
|
清单 = 源清单()
|
||||||
|
原始 = 路径摘要(根, 清单)
|
||||||
|
try:
|
||||||
|
旧缓存 = json.loads((缓存 / "状态.json").read_text())
|
||||||
|
if not isinstance(旧缓存, dict):
|
||||||
|
旧缓存 = {}
|
||||||
|
except (OSError, ValueError):
|
||||||
|
旧缓存 = {}
|
||||||
|
版本 = {"python": sys.version, "编排": 文件摘要(Path(__file__))}
|
||||||
|
for 名 in ("fastapi", "pydantic", "pyyaml"):
|
||||||
|
版本[名] = importlib.metadata.version(名)
|
||||||
|
for 名 in ("pnpm", "node", "uv"):
|
||||||
|
路径 = shutil.which(名)
|
||||||
|
if 路径 is None:
|
||||||
|
raise RuntimeError(f"缺少已安装工具:{名}")
|
||||||
|
版本[名] = 文件摘要(Path(路径).resolve())
|
||||||
|
for 名 in ("typescript", "vite", "openapi-typescript"):
|
||||||
|
文件 = 根 / "web/node_modules" / 名 / "package.json"
|
||||||
|
版本[名] = json.loads(文件.read_text())["version"]
|
||||||
|
新缓存: dict[str, Any] = {}
|
||||||
|
事件: list[dict] = []
|
||||||
|
待激活: list[str] = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="构建-", dir=缓存) as 临时:
|
||||||
|
暂存 = Path(临时)
|
||||||
|
|
||||||
|
# 同批全部步骤在暂存树运行;任何失败均不触及已有发布输出。
|
||||||
|
def 复制(相对: str) -> None:
|
||||||
|
源 = 根 / 相对
|
||||||
|
目标 = 暂存 / 相对
|
||||||
|
if not 源.exists():
|
||||||
|
return
|
||||||
|
目标.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if 源.is_dir():
|
||||||
|
if 源.is_symlink() or any(p.is_symlink() for p in 源.rglob("*")):
|
||||||
|
raise ValueError(f"构建产物目录不得含软链:{相对}")
|
||||||
|
shutil.copytree(源, 目标, dirs_exist_ok=True)
|
||||||
|
else:
|
||||||
|
shutil.copyfile(源, 目标)
|
||||||
|
|
||||||
|
for 相对 in 清单 + [接口, "web/dist", "src/muse/资源"]:
|
||||||
|
复制(相对)
|
||||||
|
(暂存 / "web/node_modules").symlink_to((根 / "web/node_modules").resolve())
|
||||||
|
环境 = {
|
||||||
|
**os.environ,
|
||||||
|
"PYTHONPATH": str(暂存 / "src"),
|
||||||
|
"PYTHONDONTWRITEBYTECODE": "1",
|
||||||
|
"UV_OFFLINE": "true",
|
||||||
|
"UV_NO_SYNC": "true",
|
||||||
|
}
|
||||||
|
环境["SOURCE_DATE_EPOCH"] = "1767225600"
|
||||||
|
|
||||||
|
def 执行(名称: str, 输入: list[str], 输出: list[str], 命令: list[list[str]]) -> None:
|
||||||
|
指纹 = 摘要({"输入": 路径摘要(暂存, 输入), "版本": 版本})
|
||||||
|
旧 = 旧缓存.get(名称, {})
|
||||||
|
if not isinstance(旧, dict):
|
||||||
|
旧 = {}
|
||||||
|
当前 = 路径摘要(根, 输出)
|
||||||
|
命中 = (
|
||||||
|
bool(当前)
|
||||||
|
and all((根 / p).exists() for p in 输出)
|
||||||
|
and 旧.get("输入") == 指纹
|
||||||
|
and 旧.get("输出") == 当前
|
||||||
|
)
|
||||||
|
if not 命中:
|
||||||
|
for 参数 in 命令:
|
||||||
|
subprocess.run(参数, cwd=暂存, env=环境, check=True)
|
||||||
|
if not all((暂存 / p).exists() for p in 输出):
|
||||||
|
raise RuntimeError(f"{名称} 未产生约定输出")
|
||||||
|
待激活.extend(输出)
|
||||||
|
当前 = 路径摘要(暂存, 输出)
|
||||||
|
新缓存[名称] = {"输入": 指纹, "输出": 当前}
|
||||||
|
事件.append({"步骤": 名称, "执行": not 命中})
|
||||||
|
|
||||||
|
py = [p for p in 清单 if p.startswith("src/") or p in {"pyproject.toml", "uv.lock"}]
|
||||||
|
执行(
|
||||||
|
"OpenAPI",
|
||||||
|
py + ["工具/生成接口.py", "工具/生成输入.json"],
|
||||||
|
[接口, 客户端],
|
||||||
|
[[sys.executable, "工具/生成接口.py"]],
|
||||||
|
)
|
||||||
|
执行(
|
||||||
|
"TypeScript",
|
||||||
|
[接口, "web/package.json", "web/pnpm-lock.yaml"],
|
||||||
|
[类型],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"pnpm",
|
||||||
|
"--dir",
|
||||||
|
"web",
|
||||||
|
"exec",
|
||||||
|
"openapi-typescript",
|
||||||
|
"../" + 接口,
|
||||||
|
"-o",
|
||||||
|
"src/接口/生成/类型.ts",
|
||||||
|
]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
web = [p for p in 清单 if p.startswith("web/")]
|
||||||
|
# 发布只检查生产源码;完整 src/tests 类型门仍由 make 前端检查承担。
|
||||||
|
(暂存 / "web/tsconfig.生产.json").write_text(
|
||||||
|
json.dumps({"extends": "./tsconfig.json", "include": ["src"]}) + "\n"
|
||||||
|
)
|
||||||
|
执行(
|
||||||
|
"web",
|
||||||
|
web + [类型, 客户端],
|
||||||
|
["web/dist"],
|
||||||
|
[
|
||||||
|
[
|
||||||
|
"pnpm",
|
||||||
|
"--dir",
|
||||||
|
"web",
|
||||||
|
"exec",
|
||||||
|
"tsc",
|
||||||
|
"--project",
|
||||||
|
"tsconfig.生产.json",
|
||||||
|
"--noEmit",
|
||||||
|
],
|
||||||
|
["pnpm", "--dir", "web", "exec", "vite", "build"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
资源 = [p for p in 清单 if not p.startswith("web/")]
|
||||||
|
执行(
|
||||||
|
"资源", 资源 + ["web/dist"], ["src/muse/资源"], [[sys.executable, "工具/构建资源包.py"]]
|
||||||
|
)
|
||||||
|
wheel = None
|
||||||
|
if 轮子:
|
||||||
|
import tomllib
|
||||||
|
|
||||||
|
配置 = tomllib.loads((暂存 / "pyproject.toml").read_text())
|
||||||
|
包名 = 配置["project"]["name"].replace("-", "_")
|
||||||
|
wheel = f"dist/{包名}-{配置['project']['version']}-py3-none-any.whl"
|
||||||
|
执行(
|
||||||
|
"wheel",
|
||||||
|
py + ["src/muse/资源", "README.md"],
|
||||||
|
[wheel],
|
||||||
|
[["uv", "build", "--offline", "--wheel", "--out-dir", "dist"]],
|
||||||
|
)
|
||||||
|
elif "wheel" in 旧缓存:
|
||||||
|
新缓存["wheel"] = 旧缓存["wheel"]
|
||||||
|
# 构建期间输入有变化则拒绝发布,避免不同时间点的代码和清单混装。
|
||||||
|
if 源清单() != 清单 or 路径摘要(根, 清单) != 原始:
|
||||||
|
raise RuntimeError("构建期间源码发生变化,拒绝发布本轮产物")
|
||||||
|
备份 = 暂存 / "原输出"
|
||||||
|
已移动: list[str] = []
|
||||||
|
try:
|
||||||
|
for 相对 in dict.fromkeys(待激活):
|
||||||
|
目标, 保存 = 根 / 相对, 备份 / 相对
|
||||||
|
目标.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
保存.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if 目标.exists():
|
||||||
|
目标.rename(保存)
|
||||||
|
已移动.append(相对)
|
||||||
|
(暂存 / 相对).rename(目标)
|
||||||
|
原子写(缓存 / "状态.json", 新缓存)
|
||||||
|
except BaseException:
|
||||||
|
for 相对 in reversed(已移动):
|
||||||
|
目标, 保存 = 根 / 相对, 备份 / 相对
|
||||||
|
if 目标.is_dir():
|
||||||
|
shutil.rmtree(目标)
|
||||||
|
elif 目标.exists():
|
||||||
|
目标.unlink()
|
||||||
|
if 保存.exists():
|
||||||
|
保存.rename(目标)
|
||||||
|
raise
|
||||||
|
return {
|
||||||
|
"步骤": 事件,
|
||||||
|
"执行数": sum(e["执行"] for e in 事件),
|
||||||
|
"wheel": str(根 / wheel) if wheel else None,
|
||||||
|
"工具身份": 版本,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def 执行构建(轮子: bool = False) -> dict:
|
||||||
|
"""共享聚合入口;进程锁覆盖构建和激活,返回本批实际 wheel 路径。"""
|
||||||
|
缓存.mkdir(exist_ok=True)
|
||||||
|
with (缓存 / "构建.lock").open("w") as 锁:
|
||||||
|
fcntl.flock(锁, fcntl.LOCK_EX)
|
||||||
|
return 构建(轮子)
|
||||||
|
|
||||||
|
|
||||||
|
def 主() -> int:
|
||||||
|
解析器 = argparse.ArgumentParser(description=__doc__)
|
||||||
|
解析器.add_argument("--wheel", action="store_true")
|
||||||
|
解析器.add_argument("--报告", type=Path)
|
||||||
|
参数 = 解析器.parse_args()
|
||||||
|
# 生成会改写 src/muse/资源;验证在跑时改写会让本轮结论作废。
|
||||||
|
from 验证锁 import 正在验证
|
||||||
|
|
||||||
|
说明 = 正在验证()
|
||||||
|
if 说明:
|
||||||
|
print(f"拒绝生成:验证正在运行({说明});等它结束再改源码与资源", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
报告 = 执行构建(参数.wheel)
|
||||||
|
if 参数.报告:
|
||||||
|
参数.报告.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
原子写(参数.报告, 报告)
|
||||||
|
print(json.dumps(报告, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(主())
|
||||||
126
工具/构建资源包.py
126
工具/构建资源包.py
@ -15,12 +15,14 @@ import argparse
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import shutil
|
import shutil
|
||||||
|
import tempfile
|
||||||
import tomllib
|
import tomllib
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path, PurePosixPath
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from muse.资源加载 import 解析能力声明
|
from muse.资源加载 import 解析能力声明
|
||||||
|
from 效果依赖 import 构建效果依赖
|
||||||
|
|
||||||
仓库根 = Path(__file__).resolve().parent.parent
|
仓库根 = Path(__file__).resolve().parent.parent
|
||||||
登记文件 = 仓库根 / "工具" / "资源登记.json"
|
登记文件 = 仓库根 / "工具" / "资源登记.json"
|
||||||
@ -78,7 +80,67 @@ def 编译能力资源(
|
|||||||
return 目录, 资源
|
return 目录, 资源
|
||||||
|
|
||||||
|
|
||||||
def 打包() -> dict[str, Any]:
|
def _仓内源(相对: str, *, 目录: bool = False) -> Path:
|
||||||
|
路径 = 仓库根 / 相对
|
||||||
|
if Path(相对).is_absolute() or not 路径.resolve().is_relative_to(仓库根.resolve()):
|
||||||
|
raise ValueError(f"登记源必须位于仓库内:{相对}")
|
||||||
|
if not (路径.is_dir() if 目录 else 路径.is_file()):
|
||||||
|
raise ValueError(f"登记源不存在或类型不符:{相对}")
|
||||||
|
return 路径
|
||||||
|
|
||||||
|
|
||||||
|
def _预检资源(登记: dict, 能力资源: dict[str, bytes]) -> tuple[list[Path], list[Path]]:
|
||||||
|
"""先统一核对源边界和所有目标身份;此阶段不创建 ZIP 或读取 web 文件字节。"""
|
||||||
|
目标们: set[str] = set()
|
||||||
|
|
||||||
|
def 登记目标(路径: str) -> None:
|
||||||
|
规范 = PurePosixPath(路径)
|
||||||
|
if (
|
||||||
|
not 路径
|
||||||
|
or "\\" in 路径
|
||||||
|
or "\x00" in 路径
|
||||||
|
or 规范.is_absolute()
|
||||||
|
or (规范.parts and ":" in 规范.parts[0])
|
||||||
|
or ".." in 规范.parts
|
||||||
|
or 规范.as_posix() != 路径
|
||||||
|
or 路径 == "."
|
||||||
|
):
|
||||||
|
raise ValueError(f"资源目标必须是规范化相对路径:{路径}")
|
||||||
|
if 路径 in 目标们:
|
||||||
|
raise ValueError(f"资源目标重复:{路径}")
|
||||||
|
目标们.add(路径)
|
||||||
|
|
||||||
|
for 条 in 登记["资源"]:
|
||||||
|
_仓内源(str(条["源路径"]))
|
||||||
|
登记目标(str(条["打包路径"]))
|
||||||
|
for 路径 in 能力资源:
|
||||||
|
登记目标(路径)
|
||||||
|
迁移文件 = []
|
||||||
|
if 登记.get("迁移目录"):
|
||||||
|
源目录 = _仓内源(str(登记["迁移目录"]), 目录=True)
|
||||||
|
for 文件 in sorted(源目录.glob("V*.sql")):
|
||||||
|
_仓内源(文件.relative_to(仓库根).as_posix())
|
||||||
|
登记目标(f"迁移/{文件.name}")
|
||||||
|
迁移文件.append(文件)
|
||||||
|
web_dist = 仓库根 / "web/dist"
|
||||||
|
if web_dist.is_symlink():
|
||||||
|
raise ValueError("web/dist 不得是软链")
|
||||||
|
web文件 = []
|
||||||
|
if web_dist.exists():
|
||||||
|
if not web_dist.is_dir():
|
||||||
|
raise ValueError("web/dist 必须是目录")
|
||||||
|
后代 = sorted(web_dist.rglob("*"))
|
||||||
|
if any(p.is_symlink() for p in 后代):
|
||||||
|
raise ValueError("web/dist 后代不得含软链")
|
||||||
|
for 文件 in 后代:
|
||||||
|
if 文件.is_file():
|
||||||
|
_仓内源(文件.relative_to(仓库根).as_posix())
|
||||||
|
登记目标("工作台/" + 文件.relative_to(web_dist).as_posix())
|
||||||
|
web文件.append(文件)
|
||||||
|
return 迁移文件, web文件
|
||||||
|
|
||||||
|
|
||||||
|
def _写入资源(资源目录: Path) -> dict[str, Any]:
|
||||||
登记 = 读取登记()
|
登记 = 读取登记()
|
||||||
发布身份 = str(登记.get("发布身份", ""))
|
发布身份 = str(登记.get("发布身份", ""))
|
||||||
if not 发布身份.strip():
|
if not 发布身份.strip():
|
||||||
@ -99,6 +161,7 @@ def 打包() -> dict[str, Any]:
|
|||||||
能力资源["能力/目录.json"] = json.dumps(
|
能力资源["能力/目录.json"] = json.dumps(
|
||||||
目录, ensure_ascii=False, sort_keys=True, indent=2
|
目录, ensure_ascii=False, sort_keys=True, indent=2
|
||||||
).encode()
|
).encode()
|
||||||
|
迁移文件, web文件 = _预检资源(登记, 能力资源)
|
||||||
资源目录.mkdir(parents=True, exist_ok=True)
|
资源目录.mkdir(parents=True, exist_ok=True)
|
||||||
压缩包路径 = 资源目录 / 压缩包名
|
压缩包路径 = 资源目录 / 压缩包名
|
||||||
|
|
||||||
@ -112,8 +175,9 @@ def 打包() -> dict[str, Any]:
|
|||||||
信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳)
|
信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳)
|
||||||
信息.compress_type = zipfile.ZIP_DEFLATED
|
信息.compress_type = zipfile.ZIP_DEFLATED
|
||||||
信息.external_attr = 0o644 << 16
|
信息.external_attr = 0o644 << 16
|
||||||
zf.writestr(信息, 源.read_bytes())
|
数据 = 源.read_bytes()
|
||||||
已打包[打包路径] = _sha256(源)
|
zf.writestr(信息, 数据)
|
||||||
|
已打包[打包路径] = hashlib.sha256(数据).hexdigest()
|
||||||
for 路径, 数据 in sorted(能力资源.items()):
|
for 路径, 数据 in sorted(能力资源.items()):
|
||||||
if 路径 in 已打包:
|
if 路径 in 已打包:
|
||||||
raise ValueError("生成能力资源与直接登记路径重复")
|
raise ValueError("生成能力资源与直接登记路径重复")
|
||||||
@ -133,9 +197,23 @@ def 打包() -> dict[str, Any]:
|
|||||||
if 迁移资源.exists():
|
if 迁移资源.exists():
|
||||||
shutil.rmtree(迁移资源)
|
shutil.rmtree(迁移资源)
|
||||||
迁移资源.mkdir()
|
迁移资源.mkdir()
|
||||||
for 文件 in sorted(源目录.glob("V*.sql")):
|
for 文件 in 迁移文件:
|
||||||
shutil.copyfile(文件, 迁移资源 / 文件.name)
|
数据 = 文件.read_bytes()
|
||||||
已打包[f"迁移/{文件.name}"] = _sha256(文件)
|
(迁移资源 / 文件.name).write_bytes(数据)
|
||||||
|
已打包[f"迁移/{文件.name}"] = hashlib.sha256(数据).hexdigest()
|
||||||
|
|
||||||
|
# 效果依赖按登记/编译/迁移生成阶段取全量保守闭包,不纳入工作台产物。
|
||||||
|
效果依赖 = 构建效果依赖(
|
||||||
|
仓库根,
|
||||||
|
dict(已打包),
|
||||||
|
{
|
||||||
|
"registered": sorted(str(条["打包路径"]) for 条 in 条目),
|
||||||
|
"capabilities": sorted(能力资源),
|
||||||
|
"migrations": sorted(
|
||||||
|
set(已打包) - {str(条["打包路径"]) for 条 in 条目} - set(能力资源)
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
# 工作台投影:web 构建产物存在时带上;不存在时如实留空。
|
# 工作台投影:web 构建产物存在时带上;不存在时如实留空。
|
||||||
工作台目录 = 资源目录 / "工作台"
|
工作台目录 = 资源目录 / "工作台"
|
||||||
@ -143,14 +221,20 @@ def 打包() -> dict[str, Any]:
|
|||||||
shutil.rmtree(工作台目录)
|
shutil.rmtree(工作台目录)
|
||||||
web_dist = 仓库根 / "web" / "dist"
|
web_dist = 仓库根 / "web" / "dist"
|
||||||
if web_dist.is_dir():
|
if web_dist.is_dir():
|
||||||
shutil.copytree(web_dist, 工作台目录)
|
工作台目录.mkdir()
|
||||||
for 文件 in sorted(工作台目录.rglob("*")):
|
for 文件 in web文件:
|
||||||
if 文件.is_file():
|
if 文件.is_symlink() or any(p.is_symlink() for p in 文件.parents if p != 仓库根):
|
||||||
相对 = 文件.relative_to(资源目录).as_posix()
|
raise ValueError("web/dist 在预检后变为软链")
|
||||||
已打包[相对] = _sha256(文件)
|
相对 = 文件.relative_to(web_dist)
|
||||||
|
目标 = 工作台目录 / 相对
|
||||||
|
目标.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
数据 = 文件.read_bytes()
|
||||||
|
目标.write_bytes(数据)
|
||||||
|
已打包["工作台/" + 相对.as_posix()] = hashlib.sha256(数据).hexdigest()
|
||||||
|
|
||||||
清单 = {
|
清单 = {
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
|
"效果依赖": 效果依赖,
|
||||||
"发布身份": 发布身份,
|
"发布身份": 发布身份,
|
||||||
"资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())},
|
"资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())},
|
||||||
}
|
}
|
||||||
@ -173,6 +257,24 @@ def 打包() -> dict[str, Any]:
|
|||||||
return 清单
|
return 清单
|
||||||
|
|
||||||
|
|
||||||
|
def 打包() -> dict[str, Any]:
|
||||||
|
"""在独立目录完成全部输入校验与输出,再替换旧资源;失败保留旧产物。"""
|
||||||
|
资源目录.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix=".资源构建-", dir=资源目录.parent) as 临时:
|
||||||
|
暂存 = Path(临时) / "新资源"
|
||||||
|
清单 = _写入资源(暂存)
|
||||||
|
旧资源 = Path(临时) / "旧资源"
|
||||||
|
if 资源目录.exists():
|
||||||
|
资源目录.rename(旧资源)
|
||||||
|
try:
|
||||||
|
暂存.rename(资源目录)
|
||||||
|
except BaseException:
|
||||||
|
if 旧资源.exists():
|
||||||
|
旧资源.rename(资源目录)
|
||||||
|
raise
|
||||||
|
return 清单
|
||||||
|
|
||||||
|
|
||||||
def 主() -> int:
|
def 主() -> int:
|
||||||
解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单")
|
解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单")
|
||||||
解析器.parse_args()
|
解析器.parse_args()
|
||||||
|
|||||||
92
工具/源码资源.py
Normal file
92
工具/源码资源.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""开发测试显式源资源视图,不生成发布包,也不提供安装资格。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from muse.共享.错误 import 资源缺失错误
|
||||||
|
|
||||||
|
|
||||||
|
class 源码资源提供器:
|
||||||
|
"""只读取登记的工程资源;测试装配必须显式注入。"""
|
||||||
|
|
||||||
|
def __init__(self, 根: Path):
|
||||||
|
self._根 = 根
|
||||||
|
self._已加载 = False
|
||||||
|
self._资源: dict[str, bytes] = {}
|
||||||
|
self._清单: dict[str, Any] = {}
|
||||||
|
|
||||||
|
def _确保加载(self) -> None:
|
||||||
|
if self._已加载:
|
||||||
|
return
|
||||||
|
根 = self._根
|
||||||
|
from muse.接入.http.应用 import 导出合同
|
||||||
|
from 构建资源包 import 编译能力资源
|
||||||
|
|
||||||
|
登记 = json.loads((根 / "工具/资源登记.json").read_text())
|
||||||
|
for 项 in 登记["资源"]:
|
||||||
|
源 = (根 / 项["源路径"]).resolve()
|
||||||
|
if not 源.is_relative_to(根.resolve()):
|
||||||
|
raise ValueError("源码资源越过登记根")
|
||||||
|
self._资源[项["打包路径"]] = 源.read_bytes()
|
||||||
|
命令 = {
|
||||||
|
操作["operationId"]
|
||||||
|
for 路径 in 导出合同()["paths"].values()
|
||||||
|
for 操作 in 路径.values()
|
||||||
|
if isinstance(操作, dict) and "operationId" in 操作
|
||||||
|
}
|
||||||
|
目录, 能力 = 编译能力资源(根, 登记.get("能力文件", []), 命令)
|
||||||
|
self._资源.update(能力)
|
||||||
|
self._资源["能力/目录.json"] = json.dumps(
|
||||||
|
目录, ensure_ascii=False, sort_keys=True, indent=2
|
||||||
|
).encode()
|
||||||
|
for 文件 in sorted((根 / 登记["迁移目录"]).glob("V*.sql")):
|
||||||
|
self._资源[f"迁移/{文件.name}"] = 文件.read_bytes()
|
||||||
|
资源表 = {
|
||||||
|
路径: {"sha256": hashlib.sha256(数据).hexdigest()}
|
||||||
|
for 路径, 数据 in sorted(self._资源.items())
|
||||||
|
}
|
||||||
|
身份 = hashlib.sha256(json.dumps(资源表, sort_keys=True).encode()).hexdigest()
|
||||||
|
self._清单 = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"资源来源": "开发源码",
|
||||||
|
"发布身份": "development-source",
|
||||||
|
"构建身份": 身份,
|
||||||
|
"代码版本": "development",
|
||||||
|
"代码哈希": "development-not-an-installation",
|
||||||
|
"合同版本": 1,
|
||||||
|
"资源": 资源表,
|
||||||
|
}
|
||||||
|
|
||||||
|
from 效果依赖 import 构建效果依赖
|
||||||
|
|
||||||
|
登记路径 = {项["打包路径"] for 项 in 登记["资源"]}
|
||||||
|
能力路径 = {*能力, "能力/目录.json"}
|
||||||
|
self._清单["效果依赖"] = 构建效果依赖(
|
||||||
|
根,
|
||||||
|
{路径: 项["sha256"] for 路径, 项 in 资源表.items()},
|
||||||
|
{
|
||||||
|
"registered": sorted(登记路径),
|
||||||
|
"capabilities": sorted(能力路径),
|
||||||
|
"migrations": sorted(set(资源表) - 登记路径 - 能力路径),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self._已加载 = True
|
||||||
|
|
||||||
|
def 加载清单(self) -> dict[str, Any]:
|
||||||
|
self._确保加载()
|
||||||
|
# 返回独立结构,测试不能污染后续用例的共享输入。
|
||||||
|
return json.loads(json.dumps(self._清单))
|
||||||
|
|
||||||
|
def 打开资源(self, 名称: str) -> bytes:
|
||||||
|
self._确保加载()
|
||||||
|
try:
|
||||||
|
return self._资源[名称]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise 资源缺失错误(f"源码测试资源未登记:{名称}") from exc
|
||||||
|
|
||||||
|
def 核对资源(self) -> dict[str, Any]:
|
||||||
|
return self.加载清单()
|
||||||
132
工具/环境预检.py
132
工具/环境预检.py
@ -9,42 +9,96 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from muse.共享.错误 import Muse错误, 配置错误
|
from muse.共享.错误 import Muse错误, 环境缺失错误, 资源缺失错误, 配置错误
|
||||||
from muse.启动 import 构建
|
from muse.启动 import 构建
|
||||||
from muse.资源加载 import 核对资源, 读取能力目录
|
from muse.资源加载 import 核对资源, 读取能力目录
|
||||||
from muse.配置 import 读取配置
|
from muse.配置 import 读取配置
|
||||||
|
|
||||||
|
|
||||||
|
def 安全失败(动作: str, 错误: Exception) -> dict:
|
||||||
|
"""输出固定原因,不展示异常文本、命令、stdout/stderr 或配置上下文。"""
|
||||||
|
可重试 = False
|
||||||
|
详情 = {}
|
||||||
|
if isinstance(错误, subprocess.TimeoutExpired):
|
||||||
|
类别, 说明, 可重试 = "TIMEOUT", "所选检查超过时限", True
|
||||||
|
elif isinstance(错误, subprocess.CalledProcessError):
|
||||||
|
类别, 说明 = "PROCESS_FAILED", "所选检查进程非零退出"
|
||||||
|
if isinstance(错误.returncode, int):
|
||||||
|
详情 = {"exit_code": 错误.returncode}
|
||||||
|
elif isinstance(错误, PermissionError):
|
||||||
|
类别, 说明 = "PERMISSION_DENIED", "所选检查没有所需文件或执行权限"
|
||||||
|
elif isinstance(错误, FileNotFoundError):
|
||||||
|
类别, 说明 = "NOT_FOUND", "所选检查需要的文件或可执行程序不存在"
|
||||||
|
elif isinstance(错误, 资源缺失错误):
|
||||||
|
类别, 说明 = "RESOURCE_INVALID", "安装资源缺失或完整性核对失败"
|
||||||
|
elif isinstance(错误, 环境缺失错误):
|
||||||
|
类别, 说明 = "ENVIRONMENT_MISSING", "所选检查需要的环境未就绪"
|
||||||
|
elif isinstance(错误, 配置错误):
|
||||||
|
类别, 说明 = "CONFIG_INVALID", "所选检查的显式配置或版本要求不匹配"
|
||||||
|
elif isinstance(错误, Muse错误) and 错误.错误码 == "MUSE_DB_CONNECTION":
|
||||||
|
类别, 说明, 可重试 = "CONNECTION_FAILED", "数据库连接或会话初始化失败", True
|
||||||
|
elif isinstance(错误, Muse错误):
|
||||||
|
类别, 说明 = "REJECTED", "所选检查被运行合同拒绝"
|
||||||
|
elif isinstance(错误, (ValueError, KeyError, TypeError)):
|
||||||
|
类别, 说明 = "INVALID_DATA", "所选检查的数据格式、字段或版本无效"
|
||||||
|
elif isinstance(错误, OSError):
|
||||||
|
类别, 说明 = "IO_FAILED", "所选检查的文件、执行或连接操作失败"
|
||||||
|
else:
|
||||||
|
类别, 说明 = "INTERNAL_ERROR", "所选检查遇到未预期错误"
|
||||||
|
return {
|
||||||
|
"code": f"PREFLIGHT_{动作}_{类别}",
|
||||||
|
"action": 动作,
|
||||||
|
"message": 说明,
|
||||||
|
"retryable": 可重试,
|
||||||
|
"details": 详情,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class 预检失败(Exception):
|
||||||
|
def __init__(self, 报告: dict) -> None:
|
||||||
|
self.报告 = 报告
|
||||||
|
super().__init__(报告["message"])
|
||||||
|
|
||||||
|
|
||||||
def 检查Pi(node: Path, cli: Path, 预期版本: str) -> dict:
|
def 检查Pi(node: Path, cli: Path, 预期版本: str) -> dict:
|
||||||
if not node.is_file() or not cli.is_file():
|
动作 = "HOST_NODE"
|
||||||
raise 配置错误("Node或Pi入口不存在;需要明确的本地可执行路径")
|
try:
|
||||||
结果 = subprocess.run(
|
if not node.is_file():
|
||||||
[str(node), "--version"], capture_output=True, text=True, timeout=10, check=True
|
raise FileNotFoundError
|
||||||
)
|
|
||||||
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",
|
|
||||||
}
|
|
||||||
结果 = subprocess.run(
|
结果 = subprocess.run(
|
||||||
[str(node), str(cli), "--version"],
|
[str(node), "--version"], capture_output=True, text=True, timeout=10, check=True
|
||||||
cwd=临时,
|
|
||||||
env=环境,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=15,
|
|
||||||
check=True,
|
|
||||||
)
|
)
|
||||||
实际 = 结果.stdout.strip()
|
node_version = 结果.stdout.strip().removeprefix("v")
|
||||||
if 实际 != 预期版本:
|
parts = tuple(int(v) for v in node_version.split("."))
|
||||||
raise 配置错误("Pi实际版本与显式要求不符")
|
if len(parts) != 3 or any(v < 0 for v in parts):
|
||||||
return {"node_version": node_version, "pi_version": 实际, "state": "version_verified"}
|
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:
|
def 主() -> int:
|
||||||
@ -61,6 +115,7 @@ def 主() -> int:
|
|||||||
(参数.node, 参数.pi, 参数.pi_version)
|
(参数.node, 参数.pi, 参数.pi_version)
|
||||||
):
|
):
|
||||||
解析.error("Pi检查必须同时给出node、pi和pi-version")
|
解析.error("Pi检查必须同时给出node、pi和pi-version")
|
||||||
|
动作 = "RESOURCE"
|
||||||
try:
|
try:
|
||||||
清单 = 核对资源()
|
清单 = 核对资源()
|
||||||
能力 = 读取能力目录()
|
能力 = 读取能力目录()
|
||||||
@ -76,25 +131,26 @@ def 主() -> int:
|
|||||||
"model_calls": 0,
|
"model_calls": 0,
|
||||||
}
|
}
|
||||||
if 参数.配置:
|
if 参数.配置:
|
||||||
|
动作 = "CONFIG"
|
||||||
配置 = 读取配置(参数.配置)
|
配置 = 读取配置(参数.配置)
|
||||||
if 配置.资源发布身份 != 清单["构建身份"]:
|
if 配置.资源发布身份 != 清单["构建身份"]:
|
||||||
raise 配置错误("配置必须固定当前资源构建身份;先无配置运行预检取得身份")
|
raise 配置错误("配置必须固定当前资源构建身份;先无配置运行预检取得身份")
|
||||||
if 参数.数据库:
|
if 参数.数据库:
|
||||||
构建(配置).要求数据库().检查连接()
|
动作 = "DATABASE"
|
||||||
|
# 常驻连接池只在装配生命周期内打开;不进生命周期会以「连接池未就绪」必然失败。
|
||||||
|
装配 = 构建(配置)
|
||||||
|
with 装配.生命周期():
|
||||||
|
装配.要求数据库().检查连接()
|
||||||
报告["database"] = "connection_verified"
|
报告["database"] = "connection_verified"
|
||||||
if 参数.node:
|
if 参数.node:
|
||||||
|
动作 = "HOST"
|
||||||
报告["host"] = 检查Pi(参数.node.resolve(), 参数.pi.resolve(), 参数.pi_version)
|
报告["host"] = 检查Pi(参数.node.resolve(), 参数.pi.resolve(), 参数.pi_version)
|
||||||
print(json.dumps(报告, ensure_ascii=False, indent=2))
|
print(json.dumps(报告, ensure_ascii=False, indent=2))
|
||||||
return 0
|
return 0
|
||||||
except Muse错误 as exc:
|
except 预检失败 as 错误:
|
||||||
print(json.dumps(exc.呈现(), ensure_ascii=False))
|
print(json.dumps(错误.报告, ensure_ascii=False))
|
||||||
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
|
except Exception as 错误:
|
||||||
print(
|
print(json.dumps(安全失败(动作, 错误), ensure_ascii=False))
|
||||||
json.dumps(
|
|
||||||
{"code": "PREFLIGHT_FAILED", "message": "资源或宿主检查失败,未验证项目不计通过"},
|
|
||||||
ensure_ascii=False,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
168
工具/生成接口.py
168
工具/生成接口.py
@ -1,6 +1,7 @@
|
|||||||
"""从登记的实际路由工厂生成 OpenAPI;不启动应用、不连接数据库或读取凭据。
|
"""从登记路由工厂生成 OpenAPI 与客户端外壳;类型由后续步骤生成。
|
||||||
|
|
||||||
未支持的非空输入失败,生成结果由同一公开合同产生。
|
先核对全部登记与输出位置,再导入工厂并生成内存合同,最后写入。
|
||||||
|
不启动应用、不连接数据库或读取凭据。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -8,55 +9,146 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
|
import keyword
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
仓库根 = Path(__file__).resolve().parent.parent
|
仓库根 = Path(__file__).resolve().parent.parent
|
||||||
登记文件 = 仓库根 / "工具" / "生成输入.json"
|
登记文件 = 仓库根 / "工具" / "生成输入.json"
|
||||||
|
_客户端路径 = "web/src/接口/生成/客户端.ts"
|
||||||
|
_客户端正文 = (
|
||||||
|
"// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n"
|
||||||
|
'import createClient from "openapi-fetch";\n'
|
||||||
|
'import type { paths } from "./类型";\n'
|
||||||
|
'import { 传输 } from "../传输";\n\n'
|
||||||
|
"export const 客户端 = createClient<paths>({\n"
|
||||||
|
" baseUrl: globalThis.location.origin, fetch: 传输,\n"
|
||||||
|
' credentials: "same-origin"\n'
|
||||||
|
"});\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class 生成错误(ValueError):
|
||||||
|
"""仅携带可安全展示的登记或合同错误,不包含原始工厂异常。"""
|
||||||
|
|
||||||
|
|
||||||
def 已登记输入() -> dict[str, Any]:
|
def 已登记输入() -> dict[str, Any]:
|
||||||
"""读取生成登记;文件不存在视为空登记(如实报告,不猜测)。"""
|
"""缺登记与坏登记都拒绝;显式空资源表仍是合法的无输出登记。"""
|
||||||
if not 登记文件.is_file():
|
try:
|
||||||
return {"资源": []}
|
数据 = json.loads(登记文件.read_text(encoding="utf-8"))
|
||||||
数据: Any = json.loads(登记文件.read_text(encoding="utf-8"))
|
except FileNotFoundError:
|
||||||
if not isinstance(数据, dict) or not isinstance(数据.get("资源"), list):
|
raise 生成错误("生成输入登记不存在") from None
|
||||||
raise SystemExit(f"生成输入登记不合法:{登记文件}(需要 dict 含 资源 表)")
|
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 数据
|
return 数据
|
||||||
|
|
||||||
|
|
||||||
def 主() -> int:
|
def _输出位置(地址: Any) -> Path:
|
||||||
解析器 = argparse.ArgumentParser(description="生成 Schema、OpenAPI 和 TypeScript")
|
if not isinstance(地址, str) or not 地址.strip() or "\\" in 地址:
|
||||||
解析器.parse_args()
|
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 输入["资源"]:
|
def 预检输入(输入: dict[str, Any]) -> list[tuple[str, str, Path]]:
|
||||||
print("生成接口:无登记输入,未生成任何合同(业务外壳定稿后登记)")
|
"""所有条目在导入或写入前校验;固定客户端输出同样不能逃逸。"""
|
||||||
return 0
|
if (
|
||||||
for 条目 in 输入["资源"]:
|
not isinstance(输入, dict)
|
||||||
if 条目.get("格式") != "OpenAPI" or not 条目.get("工厂") or not 条目.get("输出"):
|
or type(输入.get("schema_version")) is not int
|
||||||
raise SystemExit("生成接口:未接入此输入的生成器,未生成合同")
|
or 输入.get("schema_version") != 1
|
||||||
模块, 名称 = 条目["工厂"].split(":", 1)
|
or not isinstance(输入.get("资源"), list)
|
||||||
合同 = getattr(importlib.import_module(模块), 名称)()
|
):
|
||||||
if not isinstance(合同, dict) or "openapi" not in 合同:
|
raise 生成错误("生成输入登记需要 schema_version=1 和资源列表")
|
||||||
raise SystemExit("生成接口:工厂未返回 OpenAPI 合同")
|
计划 = []
|
||||||
输出 = 仓库根 / 条目["输出"]
|
目标集 = {_输出位置(_客户端路径)} 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)
|
输出.parent.mkdir(parents=True, exist_ok=True)
|
||||||
输出.write_text(json.dumps(合同, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
输出.write_text(正文, encoding="utf-8")
|
||||||
客户端 = 仓库根 / "web/src/接口/生成/客户端.ts"
|
return list(产物)
|
||||||
客户端.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
客户端.write_text(
|
|
||||||
"// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n"
|
def 主() -> int:
|
||||||
'import createClient from "openapi-fetch";\n'
|
解析器 = argparse.ArgumentParser(
|
||||||
'import type { paths } from "./类型";\n'
|
description="生成 OpenAPI 与客户端外壳;类型.ts 由后续 openapi-typescript 步骤生成"
|
||||||
'import { 传输 } from "../传输";\n\n'
|
)
|
||||||
"export const 客户端 = createClient<paths>({\n"
|
解析器.parse_args()
|
||||||
" baseUrl: globalThis.location.origin, fetch: 传输,\n"
|
try:
|
||||||
' credentials: "same-origin"\n'
|
产物 = 生成接口(已登记输入())
|
||||||
"});\n",
|
except 生成错误 as 错误:
|
||||||
encoding="utf-8",
|
print(f"生成接口失败,未生成合同:{错误}", file=sys.stderr)
|
||||||
)
|
return 1
|
||||||
print(f"生成接口:{条目['输出']}")
|
except OSError:
|
||||||
|
print("生成接口失败:无法写入已校验的输出位置", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
if not 产物:
|
||||||
|
print("生成接口:显式空登记,未生成任何合同")
|
||||||
|
for 输出 in 产物:
|
||||||
|
print(f"生成接口:{输出.relative_to(仓库根)}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
159
工具/用例身份.py
Normal file
159
工具/用例身份.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""pytest 源码用例身份;清单只是收集结果的导出,不参与执行选择。"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_位置字段 = {"case_id", "file", "symbol", "parameter_ids", "node_ids", "fixtures", "markers"}
|
||||||
|
|
||||||
|
|
||||||
|
def 原样参数ID(显示名: str) -> Any:
|
||||||
|
"""按 pytest 的命名对象协议保留历史中文 ID,避免普通字符串被转义。
|
||||||
|
|
||||||
|
pytest 运行时支持 __name__ 对象,但 ids 的类型声明未覆盖此分支。
|
||||||
|
"""
|
||||||
|
return SimpleNamespace(__name__=显示名)
|
||||||
|
|
||||||
|
|
||||||
|
def 读取用例身份(项: pytest.Item) -> dict[str, Any] | None:
|
||||||
|
"""从函数或 pytest.param 的唯一 marker 读取原作者元信息。"""
|
||||||
|
标记 = list(项.iter_markers("case_id"))
|
||||||
|
if not 标记:
|
||||||
|
return None
|
||||||
|
if len(标记) != 1:
|
||||||
|
raise pytest.UsageError(f"用例身份重叠:{项.nodeid}")
|
||||||
|
标 = 标记[0]
|
||||||
|
if len(标.args) != 1 or not isinstance(标.args[0], str):
|
||||||
|
raise pytest.UsageError(f"case_id 必须是一个完整字符串:{项.nodeid}")
|
||||||
|
身份 = 标.args[0]
|
||||||
|
if (
|
||||||
|
not 身份.startswith(("TC-", "NC-"))
|
||||||
|
or len(身份) <= 3
|
||||||
|
or any(字符.isspace() or 字符 in "[]" for 字符 in 身份)
|
||||||
|
):
|
||||||
|
raise pytest.UsageError(f"case_id 无效:{身份!r}({项.nodeid})")
|
||||||
|
if _位置字段.intersection(标.kwargs):
|
||||||
|
raise pytest.UsageError(f"case_id 元信息不能登记派生位置:{项.nodeid}")
|
||||||
|
try:
|
||||||
|
元信息 = json.loads(json.dumps(标.kwargs, ensure_ascii=False, allow_nan=False))
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise pytest.UsageError(f"case_id 元信息必须可导出 JSON:{项.nodeid}") from exc
|
||||||
|
return {"case_id": 身份, **元信息}
|
||||||
|
|
||||||
|
|
||||||
|
def 导出用例(项目: list[pytest.Item], 根: Path) -> list[dict[str, Any]]:
|
||||||
|
"""聚合同一函数的参数节点;不填造未提供的 given/when/then。"""
|
||||||
|
按身份: dict[str, dict[str, Any]] = {}
|
||||||
|
绑定: dict[str, str] = {}
|
||||||
|
for 项 in 项目:
|
||||||
|
元信息 = 读取用例身份(项)
|
||||||
|
if 元信息 is None:
|
||||||
|
raise pytest.UsageError(f"未标记 case_id:{项.nodeid}")
|
||||||
|
身份 = 元信息["case_id"]
|
||||||
|
函数节点 = 项.nodeid.split("[", 1)[0]
|
||||||
|
if 绑定.setdefault(身份, 函数节点) != 函数节点:
|
||||||
|
raise pytest.UsageError(f"同一 case_id 绑定不同函数:{身份}")
|
||||||
|
位置 = {
|
||||||
|
"file": 项.path.relative_to(根).as_posix(),
|
||||||
|
"symbol": getattr(项, "originalname", None) or 项.name.split("[", 1)[0],
|
||||||
|
}
|
||||||
|
条 = 按身份.setdefault(
|
||||||
|
身份,
|
||||||
|
{**元信息, **位置, "parameter_ids": [], "node_ids": [], "fixtures": [], "markers": []},
|
||||||
|
)
|
||||||
|
if any(条[键] != 值 for 键, 值 in {**元信息, **位置}.items()) or set(元信息) != (
|
||||||
|
set(条) - (_位置字段 - {"case_id"})
|
||||||
|
):
|
||||||
|
raise pytest.UsageError(f"同一 case_id 绑定不同函数或元信息:{身份}")
|
||||||
|
参数 = getattr(getattr(项, "callspec", None), "id", None)
|
||||||
|
if 参数 is not None:
|
||||||
|
条["parameter_ids"].append(参数)
|
||||||
|
条["node_ids"].append(项.nodeid)
|
||||||
|
条["fixtures"].extend(getattr(项, "fixturenames", []))
|
||||||
|
条["markers"].extend(标.name for 标 in 项.iter_markers() if 标.name != "case_id")
|
||||||
|
for 条 in 按身份.values():
|
||||||
|
for 键 in ("parameter_ids", "node_ids", "fixtures", "markers"):
|
||||||
|
条[键] = sorted(set(条[键]))
|
||||||
|
return sorted(按身份.values(), key=lambda 条: 条["case_id"])
|
||||||
|
|
||||||
|
|
||||||
|
def 选择用例(配置: pytest.Config, 项目: list[pytest.Item], 选择: list[str] | None = None) -> None:
|
||||||
|
"""按本次收集选择完整 ID 或 ID[参数 ID],并保留 JUnit 身份。"""
|
||||||
|
# 即使未指定 --case 也拒绝跨函数重号,不靠导出文件才发现身份冲突。
|
||||||
|
导出用例(项目, 配置.rootpath)
|
||||||
|
请求 = set(选择 if 选择 is not None else 配置.getoption("--case") or [])
|
||||||
|
命中: set[str] = set()
|
||||||
|
保留, 排除 = [], []
|
||||||
|
for 项 in 项目:
|
||||||
|
元信息 = 读取用例身份(项)
|
||||||
|
对应: set[str] = set()
|
||||||
|
if 元信息 is not None:
|
||||||
|
身份 = 元信息["case_id"]
|
||||||
|
参数 = getattr(getattr(项, "callspec", None), "id", None)
|
||||||
|
项.user_properties[:] = [
|
||||||
|
(键, 值) for 键, 值 in 项.user_properties if 键 not in {"case_id", "parameter_id"}
|
||||||
|
]
|
||||||
|
项.user_properties.append(("case_id", 身份))
|
||||||
|
对应.add(身份)
|
||||||
|
if 参数 is not None:
|
||||||
|
项.user_properties.append(("parameter_id", 参数))
|
||||||
|
对应.add(f"{身份}[{参数}]")
|
||||||
|
该项命中 = 请求 & 对应
|
||||||
|
命中.update(该项命中)
|
||||||
|
(保留 if not 请求 or 该项命中 else 排除).append(项)
|
||||||
|
if 请求 - 命中:
|
||||||
|
raise pytest.UsageError(f"--case 未收集到完整用例 ID 或参数:{sorted(请求 - 命中)}")
|
||||||
|
if 排除:
|
||||||
|
配置.hook.pytest_deselected(items=排除)
|
||||||
|
项目[:] = 保留
|
||||||
|
|
||||||
|
|
||||||
|
def 收集用例(根: Path, 路径: list[str] | None = None) -> list[dict[str, Any]]:
|
||||||
|
"""独立 pytest 进程仅收集;临时导出不成为收集前置或仓内缓存。"""
|
||||||
|
解释器 = 根 / ".venv/bin/python"
|
||||||
|
if not 解释器.is_file():
|
||||||
|
解释器 = Path(sys.executable)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="muse-case-collection-") as 目录:
|
||||||
|
导出 = Path(目录) / "cases.json"
|
||||||
|
命令 = [
|
||||||
|
str(解释器),
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
"--collect-only",
|
||||||
|
"-q",
|
||||||
|
"-p",
|
||||||
|
"no:cacheprovider",
|
||||||
|
f"--导出用例={导出}",
|
||||||
|
*(路径 or []),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
结果 = subprocess.run(
|
||||||
|
命令,
|
||||||
|
cwd=根,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=180,
|
||||||
|
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
|
raise ValueError(f"pytest 收集未完成:{exc}") from exc
|
||||||
|
if 结果.returncode not in (0, 5) or not 导出.is_file():
|
||||||
|
raise ValueError(
|
||||||
|
f"pytest 收集失败(exit {结果.returncode}):\n{结果.stdout}\n{结果.stderr}"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
内容 = json.loads(导出.read_text(encoding="utf-8"))
|
||||||
|
if not isinstance(内容, dict) or not isinstance(内容.get("cases"), list):
|
||||||
|
raise ValueError("导出缺少 cases 列表")
|
||||||
|
return 内容["cases"]
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise ValueError(f"pytest 用例导出无效:{exc}") from exc
|
||||||
533
工具/维护索引.py
533
工具/维护索引.py
@ -1,15 +1,8 @@
|
|||||||
"""索引生成、查重及检查。
|
"""由源文件派生导航,并复用 pytest 收集的稳定用例身份。
|
||||||
|
|
||||||
工程阶段的生成或预检入口:
|
显式 --写入 生成目录表;--检查 不写磁盘。技能说明来自 SKILL.md
|
||||||
- 核对 Markdown 索引(目录.md)表内相对链接有效;
|
frontmatter,作者导航语义随源文件保存,手写导读保留。历史逐文件设计
|
||||||
- 核对技能目录(.agent/skills/ 下分组)中每个技能有 SKILL.md,名称不重复;
|
不再参与每日源码定位门禁。所有入口可用显式根目录在临时项目复现。
|
||||||
- 核对测试符号与用例清单的 case 身份一致:仓库内每个 test_ 符号必须带
|
|
||||||
``__<case6>`` 后缀且能在清单定位;清单登记的目标符号必须在树上真实存在;
|
|
||||||
只有 print 没有 assert/raise 的用例在执行前即被标记(反假绿)。
|
|
||||||
|
|
||||||
边界:只报告差异与失败,不改变业务数据、不自行批准发布。
|
|
||||||
生成物与唯一输入一致;命令可独立重跑。所有检查函数接受显式根目录,
|
|
||||||
供测试在临时项目上验证同一语义。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -17,17 +10,29 @@ from __future__ import annotations
|
|||||||
import argparse
|
import argparse
|
||||||
import ast
|
import ast
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from fnmatch import fnmatch
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
仓库根 = Path(__file__).resolve().parent.parent
|
仓库根 = Path(__file__).resolve().parent.parent
|
||||||
_目录表行 = re.compile(r"^\|\s*([^|]+)\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|")
|
|
||||||
_用例后缀 = re.compile(r"__([0-9a-f]{6})$")
|
|
||||||
_忽略目录片段 = frozenset(
|
_忽略目录片段 = frozenset(
|
||||||
{".venv", ".venv-旧", ".git", "node_modules", "__pycache__", ".agents.local", "研究依据"}
|
{
|
||||||
|
".venv",
|
||||||
|
".venv-旧",
|
||||||
|
".git",
|
||||||
|
"node_modules",
|
||||||
|
"__pycache__",
|
||||||
|
".agents.local",
|
||||||
|
"研究依据",
|
||||||
|
"文件设计",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
# 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等)
|
# 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等)
|
||||||
# 由旧环境运行,不进入新版用例身份检查
|
# 由旧环境运行,不进入新版用例身份检查
|
||||||
@ -42,37 +47,47 @@ def _在忽略目录(路径: Path, 根: Path) -> bool:
|
|||||||
return any(片段 in 相对.parts for 片段 in _忽略目录片段)
|
return any(片段 in 相对.parts for 片段 in _忽略目录片段)
|
||||||
|
|
||||||
|
|
||||||
|
def 遍历文件(根: Path, 模式: str):
|
||||||
|
"""在进入目录前剪枝,忽略缓存和历史树不承担扫描成本。"""
|
||||||
|
for 当前, 目录名, 文件名 in os.walk(根, followlinks=False):
|
||||||
|
目录名[:] = sorted(名 for 名 in 目录名 if 名 not in _忽略目录片段)
|
||||||
|
for 名 in sorted(文件名):
|
||||||
|
if fnmatch(名, 模式):
|
||||||
|
yield Path(当前) / 名
|
||||||
|
|
||||||
|
|
||||||
def 检查目录链接(根: Path) -> list[str]:
|
def 检查目录链接(根: Path) -> list[str]:
|
||||||
"""核对仓库内 目录.md 的表格相对链接;返回问题列表。"""
|
"""核对仓库内 目录.md 的表格相对链接;返回问题列表。"""
|
||||||
问题: list[str] = []
|
问题: list[str] = []
|
||||||
for 目录文件 in 根.rglob("目录.md"):
|
for 目录文件 in 遍历文件(根, "目录.md"):
|
||||||
if _在忽略目录(目录文件, 根):
|
if _在忽略目录(目录文件, 根):
|
||||||
continue
|
continue
|
||||||
for 行号, 行 in enumerate(目录文件.read_text(encoding="utf-8").splitlines(), 1):
|
for 序, _, 原地址 in _表格行(目录文件.read_text(encoding="utf-8")):
|
||||||
匹配 = _目录表行.match(行)
|
地址 = 原地址.split("#")[0].strip()
|
||||||
if not 匹配:
|
if not 地址 or 地址 == "暂无" or 地址.startswith(("http://", "https://", "mailto:")):
|
||||||
continue
|
|
||||||
地址 = 匹配.group(3).split("#")[0].strip()
|
|
||||||
if not 地址 or 地址.startswith(("http://", "https://")):
|
|
||||||
continue
|
continue
|
||||||
目标 = (目录文件.parent / 地址).resolve()
|
目标 = (目录文件.parent / 地址).resolve()
|
||||||
if not 目标.exists():
|
if not 目标.exists():
|
||||||
问题.append(f"{目录文件.relative_to(根)}:{行号} 链接不存在:{地址}")
|
问题.append(f"{目录文件.relative_to(根)}:{序 + 1} 链接不存在:{地址}")
|
||||||
return 问题
|
return 问题
|
||||||
|
|
||||||
|
|
||||||
def _读frontmatter名(文件: Path) -> str | None:
|
def _读frontmatter(文件: Path) -> dict:
|
||||||
"""读 SKILL.md frontmatter 的 name 字段;无 frontmatter 返回 None。"""
|
"""与源文件一起移动的声明;支持 YAML 折叠及引号,不解析正文。"""
|
||||||
文本 = 文件.read_text(encoding="utf-8")
|
文本 = 文件.read_text(encoding="utf-8")
|
||||||
if not 文本.startswith("---"):
|
匹配 = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)", 文本, re.S)
|
||||||
return None
|
if 匹配 is None:
|
||||||
结束 = 文本.find("\n---", 3)
|
return {}
|
||||||
if 结束 < 0:
|
try:
|
||||||
return None
|
声明 = yaml.safe_load(匹配[1])
|
||||||
for 行 in 文本[3:结束].splitlines():
|
except yaml.YAMLError as 错误:
|
||||||
if 行.startswith("name:"):
|
raise ValueError(f"frontmatter 无法解析:{文件}") from 错误
|
||||||
return 行[5:].strip()
|
return 声明 if isinstance(声明, dict) else {}
|
||||||
return None
|
|
||||||
|
|
||||||
|
def _读frontmatter名(文件: Path) -> str | None:
|
||||||
|
名称 = _读frontmatter(文件).get("name")
|
||||||
|
return 名称 if isinstance(名称, str) else None
|
||||||
|
|
||||||
|
|
||||||
def _含中文(名称: str) -> bool:
|
def _含中文(名称: str) -> bool:
|
||||||
@ -99,7 +114,11 @@ def 检查技能目录(根: Path) -> list[str]:
|
|||||||
if not 正文 or 正文 == "---":
|
if not 正文 or 正文 == "---":
|
||||||
问题.append(f"SKILL.md 为空:{相对}")
|
问题.append(f"SKILL.md 为空:{相对}")
|
||||||
continue
|
continue
|
||||||
front名 = _读frontmatter名(技能文件)
|
try:
|
||||||
|
front名 = _读frontmatter名(技能文件)
|
||||||
|
except ValueError as 错误:
|
||||||
|
问题.append(str(错误))
|
||||||
|
continue
|
||||||
if front名 is None:
|
if front名 is None:
|
||||||
问题.append(f"SKILL.md 缺少 frontmatter name:{相对}")
|
问题.append(f"SKILL.md 缺少 frontmatter name:{相对}")
|
||||||
elif front名 != 名称:
|
elif front名 != 名称:
|
||||||
@ -117,6 +136,248 @@ def 检查技能目录(根: Path) -> list[str]:
|
|||||||
return 问题
|
return 问题
|
||||||
|
|
||||||
|
|
||||||
|
_导航表头 = "| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |"
|
||||||
|
_导航分隔 = "|------|----------|----------|----------|----------|"
|
||||||
|
_导航元信息 = re.compile(r"<!-- 导航元信息: (.*?) -->")
|
||||||
|
_表格链接 = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||||
|
|
||||||
|
|
||||||
|
def _表格行(文本: str) -> list[tuple[int, list[str], str]]:
|
||||||
|
结果 = []
|
||||||
|
for 序, 行 in enumerate(文本.splitlines()):
|
||||||
|
if not 行.startswith("|"):
|
||||||
|
continue
|
||||||
|
列 = [v.strip() for v in re.split(r"(?<!\\)\|", 行)[1:-1]]
|
||||||
|
if len(列) != 5 or 列[0] == "名称" or re.fullmatch(r"[- :]+", 列[0]):
|
||||||
|
continue
|
||||||
|
链接 = _表格链接.fullmatch(列[1])
|
||||||
|
结果.append((序, 列, 链接[2] if 链接 else 列[1]))
|
||||||
|
return 结果
|
||||||
|
|
||||||
|
|
||||||
|
def _导航声明(文件: Path) -> dict[str, str]:
|
||||||
|
if 文件.suffix != ".md" or not 文件.is_file():
|
||||||
|
return {}
|
||||||
|
匹配 = _导航元信息.findall(文件.read_text(encoding="utf-8"))
|
||||||
|
if len(匹配) > 1:
|
||||||
|
raise ValueError(f"导航元信息重复:{文件}")
|
||||||
|
if not 匹配:
|
||||||
|
return {}
|
||||||
|
数据 = json.loads(匹配[0])
|
||||||
|
if not isinstance(数据, dict) or any(
|
||||||
|
k not in {"内容描述", "使用场景", "使用要求"} or not isinstance(v, str)
|
||||||
|
for k, v in 数据.items()
|
||||||
|
):
|
||||||
|
raise ValueError(f"导航元信息应为内容描述、使用场景、使用要求的字符串对象:{文件}")
|
||||||
|
return 数据
|
||||||
|
|
||||||
|
|
||||||
|
def _标题(文件: Path) -> str:
|
||||||
|
if 文件.suffix == ".md" and 文件.is_file():
|
||||||
|
匹配 = re.search(r"^# +(.+?) *$", 文件.read_text(encoding="utf-8"), re.M)
|
||||||
|
if 匹配:
|
||||||
|
return 匹配[1]
|
||||||
|
return 文件.parent.name if 文件.name == "目录.md" else 文件.stem
|
||||||
|
|
||||||
|
|
||||||
|
def _目录文件(根: Path) -> list[Path]:
|
||||||
|
文件组 = {
|
||||||
|
p
|
||||||
|
for 树 in (根 / "docs", 根 / ".agent")
|
||||||
|
for p in 遍历文件(树, "目录.md")
|
||||||
|
if not _在忽略目录(p, 根)
|
||||||
|
}
|
||||||
|
技能根 = 根 / ".agent/skills"
|
||||||
|
if 技能根.is_dir():
|
||||||
|
文件组.update(p / "目录.md" for p in 技能根.iterdir() if p.is_dir())
|
||||||
|
return sorted(文件组)
|
||||||
|
|
||||||
|
|
||||||
|
def 导航元信息迁移方案(根: Path) -> dict[Path, str]:
|
||||||
|
"""一次性保全旧索引独有语义,返回源文件修改提案;不写磁盘。
|
||||||
|
|
||||||
|
只承接本目录的 Markdown 文件及下一级目录的入口。跨目录导读、
|
||||||
|
非 Markdown 材料简介与“暂无”是手写导航,仍留原处;不把它们变成
|
||||||
|
发布资源声明,也不从名称补造场景或要求。
|
||||||
|
"""
|
||||||
|
声明组: dict[Path, dict[str, str]] = {}
|
||||||
|
for 索引 in _目录文件(根):
|
||||||
|
if not 索引.is_file():
|
||||||
|
continue
|
||||||
|
for _, 列, 地址 in _表格行(索引.read_text(encoding="utf-8")):
|
||||||
|
if 地址.startswith(("../", "http:", "https:")) or "#" in 地址:
|
||||||
|
continue
|
||||||
|
目标 = 索引.parent / 地址
|
||||||
|
if not 目标.is_file() or 目标.suffix != ".md":
|
||||||
|
continue
|
||||||
|
_导航声明(目标)
|
||||||
|
if _导航元信息.search(目标.read_text(encoding="utf-8")):
|
||||||
|
continue
|
||||||
|
字段 = (
|
||||||
|
("使用场景", "使用要求")
|
||||||
|
if 目标.name == "SKILL.md"
|
||||||
|
else ("内容描述", "使用场景", "使用要求")
|
||||||
|
)
|
||||||
|
新声明 = {
|
||||||
|
k: 列[("名称", "相对地址", "内容描述", "使用场景", "使用要求").index(k)]
|
||||||
|
for k in 字段
|
||||||
|
}
|
||||||
|
if not any(新声明.values()):
|
||||||
|
continue
|
||||||
|
已有 = 声明组.setdefault(目标, {})
|
||||||
|
for 键, 值 in 新声明.items():
|
||||||
|
if 值 and 值 not in 已有.get(键, "").split("\n"):
|
||||||
|
已有[键] = "\n".join(filter(None, (已有.get(键, ""), 值)))
|
||||||
|
修改 = {}
|
||||||
|
for 文件, 声明 in 声明组.items():
|
||||||
|
文本 = 文件.read_text(encoding="utf-8")
|
||||||
|
注记 = "<!-- 导航元信息: " + json.dumps(声明, ensure_ascii=False) + " -->\n"
|
||||||
|
# frontmatter 必须仍在文件首部,正文及作者措辞逐字保留。
|
||||||
|
匹配 = re.match(r"\A---\r?\n.*?\r?\n---(?:\r?\n|$)", 文本, re.S)
|
||||||
|
位置 = 匹配.end() if 匹配 else 0
|
||||||
|
修改[文件] = 文本[:位置] + 注记 + 文本[位置:]
|
||||||
|
return 修改
|
||||||
|
|
||||||
|
|
||||||
|
def _转义单元格(值: str) -> str:
|
||||||
|
return " ".join(值.splitlines()).replace("|", "\\|")
|
||||||
|
|
||||||
|
|
||||||
|
def 派生目录(根: Path) -> dict[Path, str]:
|
||||||
|
"""按真实文件派生本级目录表;手写导读、分节与跨目录导航原样保留。"""
|
||||||
|
产物 = {}
|
||||||
|
技能根 = 根 / ".agent/skills"
|
||||||
|
for 索引 in _目录文件(根):
|
||||||
|
原文 = 索引.read_text(encoding="utf-8") if 索引.is_file() else ""
|
||||||
|
旧行 = _表格行(原文)
|
||||||
|
分组 = 索引.parent.parent == 技能根
|
||||||
|
if 分组:
|
||||||
|
文件组 = [
|
||||||
|
p / "SKILL.md"
|
||||||
|
for p in sorted(索引.parent.iterdir())
|
||||||
|
if p.is_dir() and (p / "SKILL.md").is_file()
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
文件组 = [
|
||||||
|
(p / "目录.md" if (p / "目录.md").is_file() else p)
|
||||||
|
for p in sorted(索引.parent.iterdir())
|
||||||
|
if p != 索引 and not p.name.startswith(".") and p.name != "__pycache__"
|
||||||
|
]
|
||||||
|
新行 = {}
|
||||||
|
for 文件 in 文件组:
|
||||||
|
地址 = 文件.relative_to(索引.parent).as_posix() + ("/" if 文件.is_dir() else "")
|
||||||
|
元信息 = _导航声明(文件)
|
||||||
|
if 分组:
|
||||||
|
声明 = _读frontmatter(文件)
|
||||||
|
名称 = str(声明.get("name", 文件.parent.name))
|
||||||
|
描述 = str(声明.get("description", ""))
|
||||||
|
else:
|
||||||
|
名称 = _标题(文件)
|
||||||
|
描述 = 元信息.get("内容描述", "")
|
||||||
|
# 非 Markdown 材料没有强塞额外元信息,保留作者已有的唯一简介。
|
||||||
|
旧 = next((列 for _, 列, 路径 in 旧行 if 路径 == 地址), None)
|
||||||
|
if 文件.suffix != ".md" and 旧:
|
||||||
|
名称, 描述 = 旧[0], 旧[2]
|
||||||
|
元信息 = dict(zip(("内容描述", "使用场景", "使用要求"), 旧[2:], strict=True))
|
||||||
|
列 = [
|
||||||
|
名称,
|
||||||
|
f"[{文件.name if 文件.is_file() else 文件.name + '/'}]({地址})",
|
||||||
|
描述,
|
||||||
|
元信息.get("使用场景", ""),
|
||||||
|
元信息.get("使用要求", ""),
|
||||||
|
]
|
||||||
|
新行[地址] = "| " + " | ".join(_转义单元格(v) for v in 列) + " |"
|
||||||
|
行组 = 原文.splitlines()
|
||||||
|
旧行映射 = {序: (列, 地址) for 序, 列, 地址 in 旧行}
|
||||||
|
结果 = []
|
||||||
|
插入位置 = None
|
||||||
|
表数量 = 0
|
||||||
|
for 序, 行 in enumerate(行组):
|
||||||
|
if 序 in 旧行映射:
|
||||||
|
_, 地址 = 旧行映射[序]
|
||||||
|
if 地址 in 新行:
|
||||||
|
结果.append(新行.pop(地址))
|
||||||
|
elif 地址.startswith(("../", "http:", "https:")) or 地址 == "暂无" or "#" in 地址:
|
||||||
|
结果.append(行) # 手写导读,不作为本级文件清单。
|
||||||
|
continue
|
||||||
|
结果.append(行)
|
||||||
|
if re.fullmatch(r"\|[- :|]+\|", 行):
|
||||||
|
表数量 += 1
|
||||||
|
if 插入位置 is None:
|
||||||
|
插入位置 = len(结果)
|
||||||
|
if 插入位置 is None:
|
||||||
|
if 结果 and 结果[-1]:
|
||||||
|
结果.append("")
|
||||||
|
结果.extend((_导航表头, _导航分隔))
|
||||||
|
插入位置 = len(结果)
|
||||||
|
if 表数量 > 1 and 新行:
|
||||||
|
# 未声明作者分节的新来源不被悄悄归到第一个语义分节。
|
||||||
|
结果.extend(("", "## 未分节条目", "", _导航表头, _导航分隔))
|
||||||
|
插入位置 = len(结果)
|
||||||
|
结果[插入位置:插入位置] = list(新行.values())
|
||||||
|
产物[索引] = "\n".join(结果).rstrip() + "\n"
|
||||||
|
return 产物
|
||||||
|
|
||||||
|
|
||||||
|
def 写入目录(根: Path) -> list[Path]:
|
||||||
|
"""显式生成导航;存量独有语义必须先迁回源文件,不静默丢弃。"""
|
||||||
|
待迁移 = 导航元信息迁移方案(根)
|
||||||
|
if 待迁移:
|
||||||
|
raise ValueError(
|
||||||
|
"索引独有语义尚未迁回源文件:" + "、".join(str(p.relative_to(根)) for p in 待迁移)
|
||||||
|
)
|
||||||
|
已写 = []
|
||||||
|
for 文件, 文本 in 派生目录(根).items():
|
||||||
|
if not 文件.is_file() or 文件.read_text(encoding="utf-8") != 文本:
|
||||||
|
文件.write_text(文本, encoding="utf-8")
|
||||||
|
已写.append(文件)
|
||||||
|
return 已写
|
||||||
|
|
||||||
|
|
||||||
|
def 检查派生目录(根: Path) -> list[str]:
|
||||||
|
"""显式索引检查比较派生结果;不要求历史设计与现行源码逐文件一致。"""
|
||||||
|
try:
|
||||||
|
return [
|
||||||
|
f"目录需要重新生成:{文件.relative_to(根)}"
|
||||||
|
for 文件, 文本 in 派生目录(根).items()
|
||||||
|
if not 文件.is_file() or 文件.read_text(encoding="utf-8") != 文本
|
||||||
|
]
|
||||||
|
except (ValueError, yaml.YAMLError) as 错误:
|
||||||
|
return [f"目录源元信息无效:{错误}"]
|
||||||
|
|
||||||
|
|
||||||
|
def 检查技能索引(根: Path) -> list[str]:
|
||||||
|
"""分组与技能双向核对的唯一实现,供命令和 pytest 共用。"""
|
||||||
|
技能根 = 根 / ".agent/skills"
|
||||||
|
if not 技能根.is_dir():
|
||||||
|
return ["技能目录不存在:.agent/skills"]
|
||||||
|
问题 = []
|
||||||
|
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()):
|
||||||
|
索引 = 分组 / "目录.md"
|
||||||
|
if not 索引.is_file():
|
||||||
|
问题.append(f"分组缺目录:{分组.name}")
|
||||||
|
continue
|
||||||
|
索引名 = [
|
||||||
|
地址
|
||||||
|
for _, _, 地址 in _表格行(索引.read_text(encoding="utf-8"))
|
||||||
|
if 地址.endswith("/SKILL.md")
|
||||||
|
]
|
||||||
|
磁盘名 = {
|
||||||
|
f"{p.name}/SKILL.md"
|
||||||
|
for p in 分组.iterdir()
|
||||||
|
if p.is_dir() and (p / "SKILL.md").is_file()
|
||||||
|
}
|
||||||
|
if set(索引名) != 磁盘名:
|
||||||
|
问题.append(
|
||||||
|
f"分组 {分组.name} 索引与磁盘不一致:"
|
||||||
|
f"索引多出 {sorted(set(索引名) - 磁盘名)},"
|
||||||
|
f"磁盘多出 {sorted(磁盘名 - set(索引名))}"
|
||||||
|
)
|
||||||
|
if len(索引名) != len(set(索引名)):
|
||||||
|
问题.append(f"分组 {分组.name} 索引重复登记技能")
|
||||||
|
return 问题
|
||||||
|
|
||||||
|
|
||||||
def 解析测试符号(文件: Path) -> list[ast.FunctionDef]:
|
def 解析测试符号(文件: Path) -> list[ast.FunctionDef]:
|
||||||
"""用 AST 提取 test_ 开头的函数与方法;不执行任何代码。"""
|
"""用 AST 提取 test_ 开头的函数与方法;不执行任何代码。"""
|
||||||
树 = ast.parse(文件.read_text(encoding="utf-8"))
|
树 = ast.parse(文件.read_text(encoding="utf-8"))
|
||||||
@ -140,144 +401,118 @@ def _有判定语句(节点: ast.FunctionDef) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def 检查用例身份(
|
def 收集索引用例(根: Path) -> list[dict[str, Any]]:
|
||||||
根: Path,
|
"""Python、前端各由本语言的唯一收集实现导出,再验证跨语言身份。"""
|
||||||
清单路径: Path | None = None,
|
from 用例身份 import 收集用例
|
||||||
*,
|
|
||||||
核对缺失目标: bool = True,
|
|
||||||
全树扫描: bool = False,
|
|
||||||
) -> list[str]:
|
|
||||||
"""核对测试符号与用例清单双向一致:
|
|
||||||
1. 新版用例目录内符号按完整文件、函数或前端用例名绑定清单;
|
|
||||||
2. 用例体必须包含可判定语句(assert/raise/fail/skip/xfail);
|
|
||||||
3. 核对缺失目标=True 时,清单登记的 (file, symbol) 必须在树上真实存在
|
|
||||||
默认双向核对,不依据历史执行状态豁免目标存在性。
|
|
||||||
"""
|
|
||||||
if 清单路径 is None:
|
|
||||||
清单路径 = 根 / "tests" / "用例清单.json"
|
|
||||||
问题: list[str] = []
|
|
||||||
if not 清单路径.is_file():
|
|
||||||
return [f"用例清单不存在:{清单路径}"]
|
|
||||||
清单: dict[str, Any] = json.loads(清单路径.read_text(encoding="utf-8"))
|
|
||||||
|
|
||||||
登记符号: dict[str, str] = {}
|
用例 = 收集用例(根)
|
||||||
for 用例 in 清单["cases"]:
|
if (根 / "web/tests").is_dir():
|
||||||
文件, 符号 = 用例["file"], 用例["symbol"]
|
|
||||||
登记符号[f"{文件}::{符号}"] = 用例["case_id"]
|
|
||||||
|
|
||||||
树上符号: set[str] = set()
|
|
||||||
候选 = sorted(根.glob("**/test_*.py")) if 全树扫描 else sorted(根.glob("tests/**/test_*.py"))
|
|
||||||
for 测试文件 in 候选:
|
|
||||||
if _在忽略目录(测试文件, 根):
|
|
||||||
continue
|
|
||||||
if not 全树扫描:
|
|
||||||
# 只检查新版用例目录;旧测试树由旧环境运行
|
|
||||||
try:
|
|
||||||
相对路径 = 测试文件.relative_to(根 / "tests")
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if not 相对路径.parts or 相对路径.parts[0] not in _新版用例目录:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
相对 = 测试文件.relative_to(根).as_posix()
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
for 节点 in 解析测试符号(测试文件):
|
|
||||||
树上符号.add(f"{相对}::{节点.name}")
|
|
||||||
匹配 = _用例后缀.search(节点.name)
|
|
||||||
if not 匹配:
|
|
||||||
问题.append(f"测试符号缺少用例后缀:{相对}::{节点.name}")
|
|
||||||
if f"{相对}::{节点.name}" not in 登记符号:
|
|
||||||
问题.append(f"测试文件与符号未在用例清单登记:{相对}::{节点.name}")
|
|
||||||
if not _有判定语句(节点):
|
|
||||||
问题.append(f"用例无可判定语句(print 不能代替断言):{相对}::{节点.name}")
|
|
||||||
|
|
||||||
前端测试 = sorted(
|
|
||||||
[*(根 / "web/tests").rglob("*.test.ts*"), *(根 / "web/tests").rglob("*.spec.ts*")]
|
|
||||||
)
|
|
||||||
if 前端测试:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# TypeScript AST 只解析静态用例声明,不执行测试或加载其运行依赖。
|
|
||||||
脚本 = """
|
|
||||||
import ts from 'typescript';
|
|
||||||
import fs from 'node:fs';
|
|
||||||
const files = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
||||||
const found = [];
|
|
||||||
for (const file of files) {
|
|
||||||
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'),
|
|
||||||
ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
||||||
function visit(node) {
|
|
||||||
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)
|
|
||||||
&& ['it', 'test'].includes(node.expression.text)
|
|
||||||
&& node.arguments[0] && ts.isStringLiteral(node.arguments[0]))
|
|
||||||
found.push([file, node.arguments[0].text]);
|
|
||||||
ts.forEachChild(node, visit);
|
|
||||||
}
|
|
||||||
visit(source);
|
|
||||||
}
|
|
||||||
process.stdout.write(JSON.stringify(found));
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
结果 = subprocess.run(
|
结果 = subprocess.run(
|
||||||
["node", "--input-type=module", "-e", 脚本],
|
["node", str(根 / "工具/前端用例身份.mjs"), str(根)],
|
||||||
cwd=根 / "web",
|
cwd=根,
|
||||||
input=json.dumps([str(p) for p in 前端测试]),
|
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=20,
|
timeout=30,
|
||||||
check=True,
|
check=True,
|
||||||
)
|
)
|
||||||
for 文件, 符号 in json.loads(结果.stdout):
|
前端 = json.loads(结果.stdout)
|
||||||
键 = f"{Path(文件).relative_to(根).as_posix()}::{符号}"
|
if not isinstance(前端, list):
|
||||||
树上符号.add(键)
|
raise ValueError("前端用例导出必须为列表")
|
||||||
if 键 not in 登记符号:
|
用例.extend(前端)
|
||||||
问题.append(f"前端用例未在清单登记:{键}")
|
except subprocess.CalledProcessError as 错误:
|
||||||
except (OSError, ValueError, subprocess.SubprocessError):
|
raise ValueError(f"前端用例收集失败(exit {错误.returncode}):{错误.stderr}") from 错误
|
||||||
问题.append("前端用例解析失败;需要已锁定的 Node 与 TypeScript 工具链")
|
except (OSError, subprocess.TimeoutExpired) as 错误:
|
||||||
|
raise ValueError(f"前端用例收集未完成:{错误}") from 错误
|
||||||
for 键, case_id in sorted(登记符号.items()):
|
已见 = set()
|
||||||
if 核对缺失目标:
|
for 条 in 用例:
|
||||||
if 键 not in 树上符号:
|
if not isinstance(条, dict) or not isinstance(条.get("case_id"), str):
|
||||||
问题.append(f"用例清单登记的目标不存在:{键}({case_id})")
|
raise ValueError("用例导出缺少完整 case_id")
|
||||||
return 问题
|
身份 = 条["case_id"]
|
||||||
|
if 身份 in 已见:
|
||||||
|
raise ValueError(f"用例身份重复:{身份}")
|
||||||
|
已见.add(身份)
|
||||||
|
if not 用例:
|
||||||
|
raise ValueError("未收集到用例,不能通过身份检查")
|
||||||
|
return sorted(用例, key=lambda 条: 条["case_id"])
|
||||||
|
|
||||||
|
|
||||||
def 检查文件定位(根: Path) -> list[str]:
|
def _检查用例判定(根: Path) -> list[str]:
|
||||||
"""设计清单的文件定位必须恰好命中一个定义,不把目录树文字当作锚点。"""
|
问题 = []
|
||||||
设计根 = 根 / "docs/系统架构/新版设计"
|
for 文件 in sorted(遍历文件(根 / "tests", "test_*.py")):
|
||||||
清单 = 设计根 / "目标文件清单.json"
|
if _在忽略目录(文件, 根) or 文件.relative_to(根 / "tests").parts[0] not in _新版用例目录:
|
||||||
if not 清单.is_file():
|
|
||||||
return []
|
|
||||||
问题, 已读 = [], {}
|
|
||||||
for 项 in json.loads(清单.read_text(encoding="utf-8"))["files"]:
|
|
||||||
文件名, 分隔, 锚点 = 项.get("design_location", "").partition("#")
|
|
||||||
文件 = (设计根 / 文件名).resolve()
|
|
||||||
if not 分隔 or not 文件.is_relative_to(设计根.resolve()) or not 文件.is_file():
|
|
||||||
问题.append(f"文件设计定位不存在:{项['path']}")
|
|
||||||
continue
|
continue
|
||||||
if 文件 not in 已读:
|
for 节点 in 解析测试符号(文件):
|
||||||
已读[文件] = 文件.read_text(encoding="utf-8")
|
if not _有判定语句(节点):
|
||||||
次数 = len(re.findall(r'<a\s+id=["\x27]' + re.escape(锚点) + r'["\x27]', 已读[文件]))
|
问题.append(
|
||||||
if 次数 != 1:
|
f"用例无可判定语句(print 不能代替断言):{文件.relative_to(根)}::{节点.name}"
|
||||||
问题.append(f"文件设计锚点应恰一,实际{次数}:{项['path']}({锚点})")
|
)
|
||||||
return 问题
|
return 问题
|
||||||
|
|
||||||
|
|
||||||
|
def 检查用例身份(根: Path) -> list[str]:
|
||||||
|
"""源码身份由共享收集入口校验;不读取派生清单或旧后缀。"""
|
||||||
|
问题 = _检查用例判定(根)
|
||||||
|
try:
|
||||||
|
收集索引用例(根)
|
||||||
|
except ValueError as 错误:
|
||||||
|
问题.append(str(错误))
|
||||||
|
return 问题
|
||||||
|
|
||||||
|
|
||||||
|
def 写入用例清单(根: Path, 用例: list[dict[str, Any]]) -> bool:
|
||||||
|
目标 = 根 / "tests/用例清单.json"
|
||||||
|
文本 = (
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"scope": "源码收集派生;不作为执行前置,不表示执行或通过。",
|
||||||
|
"cases": 用例,
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
)
|
||||||
|
if 目标.is_file() and 目标.read_text(encoding="utf-8") == 文本:
|
||||||
|
return False
|
||||||
|
目标.write_text(文本, encoding="utf-8")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def 主() -> int:
|
def 主() -> int:
|
||||||
解析器 = argparse.ArgumentParser(description="索引生成、查重及检查")
|
解析器 = argparse.ArgumentParser(description="索引生成、查重及检查")
|
||||||
解析器.add_argument("--写入", action="store_true", help="执行索引写入(当前为幂等检查)")
|
解析器.add_argument("--写入", action="store_true", help="按源文件声明与实际文件生成导航表")
|
||||||
解析器.add_argument("--检查", action="store_true", help="只检查并报告差异")
|
解析器.add_argument("--检查", action="store_true", help="只检查并报告差异")
|
||||||
参数 = 解析器.parse_args()
|
参数 = 解析器.parse_args()
|
||||||
|
try:
|
||||||
|
用例 = 收集索引用例(仓库根)
|
||||||
|
except ValueError as 错误:
|
||||||
|
print(f"索引问题:{错误}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
if 参数.写入:
|
if 参数.写入:
|
||||||
print("索引写入:当前无可写生成物,执行幂等检查")
|
try:
|
||||||
|
源问题 = 检查技能目录(仓库根)
|
||||||
|
if 源问题:
|
||||||
|
raise ValueError(";".join(源问题))
|
||||||
|
print(f"索引写入:更新 {len(写入目录(仓库根))} 个目录")
|
||||||
|
print(f"用例导出:{len(用例)} 条,更新 {int(写入用例清单(仓库根, 用例))} 个清单")
|
||||||
|
except ValueError as 错误:
|
||||||
|
print(f"索引生成失败:{错误}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
问题 = 检查目录链接(仓库根) + 检查技能目录(仓库根) + 检查用例身份(仓库根) + 检查文件定位(仓库根)
|
问题 = (
|
||||||
|
检查目录链接(仓库根)
|
||||||
|
+ 检查技能目录(仓库根)
|
||||||
|
+ _检查用例判定(仓库根)
|
||||||
|
+ 检查技能索引(仓库根)
|
||||||
|
+ 检查派生目录(仓库根)
|
||||||
|
)
|
||||||
if 问题:
|
if 问题:
|
||||||
for 条 in 问题:
|
for 条 in 问题:
|
||||||
print(f"索引问题:{条}", file=sys.stderr)
|
print(f"索引问题:{条}", file=sys.stderr)
|
||||||
return 1
|
return 1
|
||||||
print("索引检查通过:目录链接、技能目录、用例身份及文件定位均一致")
|
print("索引检查通过:目录链接、技能目录与用例身份一致(历史逐文件设计不参与活跃门禁)")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
40
工具/资源登记.json
40
工具/资源登记.json
@ -179,30 +179,14 @@
|
|||||||
"源路径": "src/muse/审校修订/提示词/连续性审阅.md",
|
"源路径": "src/muse/审校修订/提示词/连续性审阅.md",
|
||||||
"打包路径": "审校/连续性审阅.md"
|
"打包路径": "审校/连续性审阅.md"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"源路径": "数据库/迁移/V0041__评测语义检测单元.sql",
|
|
||||||
"打包路径": "迁移/V0041__评测语义检测单元.sql"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"源路径": "src/muse/效果评测/效果标准.json",
|
"源路径": "src/muse/效果评测/效果标准.json",
|
||||||
"打包路径": "评测/效果标准.json"
|
"打包路径": "评测/效果标准.json"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"源路径": "数据库/迁移/V0042__效果判据凭据.sql",
|
|
||||||
"打包路径": "迁移/V0042__效果判据凭据.sql"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"源路径": "配置/流程模板/定稿交付.yaml",
|
"源路径": "配置/流程模板/定稿交付.yaml",
|
||||||
"打包路径": "流程/定稿交付.yaml"
|
"打包路径": "流程/定稿交付.yaml"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"源路径": "src/muse/交付连载/提示词/全书审阅.md",
|
|
||||||
"打包路径": "交付/全书审阅.md"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"源路径": "src/muse/交付连载/提示词/整理交付材料.md",
|
|
||||||
"打包路径": "交付/整理交付材料.md"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"源路径": "配置/流程模板/数据维护.yaml",
|
"源路径": "配置/流程模板/数据维护.yaml",
|
||||||
"打包路径": "流程/数据维护.yaml"
|
"打包路径": "流程/数据维护.yaml"
|
||||||
@ -230,6 +214,26 @@
|
|||||||
{
|
{
|
||||||
"源路径": "配置/流程模板/审校修订.yaml",
|
"源路径": "配置/流程模板/审校修订.yaml",
|
||||||
"打包路径": "流程/审校修订.yaml"
|
"打包路径": "流程/审校修订.yaml"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"源路径": "src/muse/效果评测/目标标准.json",
|
||||||
|
"打包路径": "评测/目标标准.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"源路径": "src/muse/作品规划/提示词/生成作品规划.md",
|
||||||
|
"打包路径": "规划/生成作品规划.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"源路径": "src/muse/作品规划/提示词/细化章节计划.md",
|
||||||
|
"打包路径": "规划/细化章节计划.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"源路径": "src/muse/审校修订/提示词/创作质量审阅.md",
|
||||||
|
"打包路径": "审校/创作质量审阅.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"源路径": ".agent/约束/创作方法与选段.md",
|
||||||
|
"打包路径": "写作/创作方法与选段.md"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"迁移目录": "数据库/迁移",
|
"迁移目录": "数据库/迁移",
|
||||||
@ -260,6 +264,8 @@
|
|||||||
".agent/skills/操作/提出并验证改进/SKILL.md",
|
".agent/skills/操作/提出并验证改进/SKILL.md",
|
||||||
".agent/skills/操作/整理定稿与导出/SKILL.md",
|
".agent/skills/操作/整理定稿与导出/SKILL.md",
|
||||||
".agent/skills/操作/登记连载与反馈/SKILL.md",
|
".agent/skills/操作/登记连载与反馈/SKILL.md",
|
||||||
".agent/skills/操作/备份与恢复数据/SKILL.md"
|
".agent/skills/操作/备份与恢复数据/SKILL.md",
|
||||||
|
".agent/skills/操作/写下一章/SKILL.md",
|
||||||
|
".agent/skills/操作/核对内容一致性/SKILL.md"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
164
工具/验收安装包.py
Normal file
164
工具/验收安装包.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"""验收指定实际 wheel:隔离解压,仓外运行,缺失及损坏资源必须拒绝。
|
||||||
|
|
||||||
|
指定 wheel 时不重新构建;省略参数则增量构建并消费本批 wheel。
|
||||||
|
不安装依赖、不启动服务;PG/浏览器/恢复验收仍由各自环境承担。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import sysconfig
|
||||||
|
import tempfile
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
_探针 = r"""
|
||||||
|
import hashlib, json, os, pathlib, socket, sys
|
||||||
|
def deny_connection(self, address):
|
||||||
|
raise AssertionError("包入口解析期间不得连接外部服务")
|
||||||
|
socket.socket.connect = deny_connection
|
||||||
|
prefix = pathlib.Path(sys.argv[1]).resolve()
|
||||||
|
source = pathlib.Path(sys.argv[3]).resolve()
|
||||||
|
def reject_source_reads(event, args):
|
||||||
|
if event in {"open", "os.listdir", "os.scandir"} and args and isinstance(args[0], (str, bytes)):
|
||||||
|
target = pathlib.Path(os.fsdecode(args[0])).resolve()
|
||||||
|
if target.is_relative_to(source) or ".git" in target.parts:
|
||||||
|
raise AssertionError("installed package attempted to read source or Git")
|
||||||
|
sys.addaudithook(reject_source_reads)
|
||||||
|
sys.path.insert(0, str(prefix))
|
||||||
|
sys.path.append(sys.argv[2])
|
||||||
|
import muse, muse.启动, muse.配置
|
||||||
|
from muse.启动 import 应用装配, 构建
|
||||||
|
assert callable(应用装配) and callable(构建)
|
||||||
|
assert pathlib.Path(muse.__file__).resolve().is_relative_to(prefix), muse.__file__
|
||||||
|
from muse.资源加载 import 核对资源
|
||||||
|
from muse.共享.错误 import 资源缺失错误
|
||||||
|
try:
|
||||||
|
manifest = 核对资源()
|
||||||
|
except 资源缺失错误 as error:
|
||||||
|
print(json.dumps({"拒绝": type(error).__name__, "原因": str(error)}, ensure_ascii=False))
|
||||||
|
sys.exit(23)
|
||||||
|
files = {"src/" + f.relative_to(prefix).as_posix(): hashlib.sha256(f.read_bytes()).hexdigest()
|
||||||
|
for f in (prefix / "muse").rglob("*")
|
||||||
|
if f.suffix in {".py", ".ts"} and "资源" not in f.relative_to(prefix / "muse").parts}
|
||||||
|
code_hash = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
|
||||||
|
assert code_hash == manifest["代码哈希"]
|
||||||
|
from muse.编排.生成规划 import 规划模板
|
||||||
|
from muse.审校修订.完整审校 import 文学模板
|
||||||
|
from muse.资源加载 import 读取能力
|
||||||
|
for kind, resource in [("outline", "规划/生成作品规划.md"),
|
||||||
|
("fine_outline", "规划/细化章节计划.md")]:
|
||||||
|
text, digest = 规划模板(kind)
|
||||||
|
assert text and digest == manifest["资源"][resource]["sha256"]
|
||||||
|
text, digest = 文学模板()
|
||||||
|
assert text and digest == manifest["资源"]["审校/创作质量审阅.md"]["sha256"]
|
||||||
|
for capability in ("write-next-chapter", "check-consistency"):
|
||||||
|
assert 读取能力("operation", capability)["正文"]
|
||||||
|
print(json.dumps({"构建身份": manifest["构建身份"], "资源数": len(manifest["资源"]),
|
||||||
|
"加载位置": str(muse.__file__)}, ensure_ascii=False))
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def 验收(轮子: Path) -> dict:
|
||||||
|
if not 轮子.is_file() or 轮子.suffix != ".whl":
|
||||||
|
raise ValueError("必须提供本批实际 wheel 文件")
|
||||||
|
包摘要 = hashlib.sha256(轮子.read_bytes()).hexdigest()
|
||||||
|
环境 = {k: v for k, v in os.environ.items() if k != "PYTHONPATH" and not k.startswith("MUSE_")}
|
||||||
|
环境["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||||
|
with tempfile.TemporaryDirectory(prefix="muse-包验收-") as 临时:
|
||||||
|
根 = Path(临时)
|
||||||
|
前缀 = 根 / "installed"
|
||||||
|
with zipfile.ZipFile(轮子) as zf:
|
||||||
|
名称们 = zf.namelist()
|
||||||
|
if len(名称们) != len(set(名称们)):
|
||||||
|
raise ValueError("wheel 存在重复路径")
|
||||||
|
for 名 in 名称们:
|
||||||
|
if Path(名).is_absolute() or {
|
||||||
|
"..",
|
||||||
|
".git",
|
||||||
|
"tests",
|
||||||
|
".agents.local",
|
||||||
|
".env",
|
||||||
|
}.intersection(Path(名).parts):
|
||||||
|
raise ValueError(f"wheel 包含不允许的路径:{名}")
|
||||||
|
zf.extractall(前缀)
|
||||||
|
结果 = []
|
||||||
|
|
||||||
|
def 运行(场景: str, 期望: int) -> None:
|
||||||
|
进程 = subprocess.run(
|
||||||
|
[
|
||||||
|
sys.executable,
|
||||||
|
"-I",
|
||||||
|
"-S",
|
||||||
|
"-B",
|
||||||
|
"-c",
|
||||||
|
_探针,
|
||||||
|
str(前缀),
|
||||||
|
sysconfig.get_path("purelib"),
|
||||||
|
str(Path(__file__).resolve().parents[1]),
|
||||||
|
],
|
||||||
|
cwd=根,
|
||||||
|
env=环境,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=120,
|
||||||
|
)
|
||||||
|
if 进程.returncode != 期望:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"{场景} exit={进程.returncode},期望={期望}\n{进程.stdout}\n{进程.stderr}"
|
||||||
|
)
|
||||||
|
结果.append({"场景": 场景, "exit": 进程.returncode, "输出": json.loads(进程.stdout)})
|
||||||
|
|
||||||
|
运行("完整实际wheel", 0)
|
||||||
|
清单文件 = 前缀 / "muse/资源/清单.json"
|
||||||
|
原清单 = 清单文件.read_bytes()
|
||||||
|
清单文件.unlink()
|
||||||
|
运行("缺清单必须拒绝", 23)
|
||||||
|
清单文件.write_bytes(原清单)
|
||||||
|
资源 = 前缀 / "muse/资源/运行资源.zip"
|
||||||
|
原包 = 资源.read_bytes()
|
||||||
|
资源.unlink()
|
||||||
|
运行("缺资源必须拒绝", 23)
|
||||||
|
资源.write_bytes(原包)
|
||||||
|
with zipfile.ZipFile(资源) as zf:
|
||||||
|
条目 = [(info, zf.read(info.filename)) for info in zf.infolist()]
|
||||||
|
with zipfile.ZipFile(资源, "w") as zf:
|
||||||
|
for 序号, (信息, 数据) in enumerate(条目):
|
||||||
|
zf.writestr(信息, 数据 + b"tamper" if 序号 == 0 else 数据)
|
||||||
|
运行("资源字节损坏必须拒绝", 23)
|
||||||
|
if hashlib.sha256(轮子.read_bytes()).hexdigest() != 包摘要:
|
||||||
|
raise RuntimeError("验收期间原 wheel 发生变化")
|
||||||
|
return {"wheel": str(轮子), "sha256": 包摘要, "场景": 结果}
|
||||||
|
|
||||||
|
|
||||||
|
def 主() -> int:
|
||||||
|
解析器 = argparse.ArgumentParser(description=__doc__)
|
||||||
|
解析器.add_argument("wheel", type=Path, nargs="?")
|
||||||
|
解析器.add_argument("--报告", type=Path)
|
||||||
|
参数 = 解析器.parse_args()
|
||||||
|
构建报告 = None
|
||||||
|
if 参数.wheel is None:
|
||||||
|
from 构建编排 import 执行构建
|
||||||
|
|
||||||
|
构建报告 = 执行构建(轮子=True)
|
||||||
|
轮子 = Path(构建报告["wheel"])
|
||||||
|
else:
|
||||||
|
轮子 = 参数.wheel.resolve()
|
||||||
|
报告 = 验收(轮子)
|
||||||
|
if 构建报告 is not None:
|
||||||
|
报告["同批构建"] = 构建报告
|
||||||
|
文本 = json.dumps(报告, ensure_ascii=False, indent=2) + "\n"
|
||||||
|
if 参数.报告:
|
||||||
|
参数.报告.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
参数.报告.write_text(文本)
|
||||||
|
print(文本)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(主())
|
||||||
159
工具/验证锁.py
Normal file
159
工具/验证锁.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""验证期写闸门:验证会话持锁,生成与格式化入口在锁被持有时拒绝改写。
|
||||||
|
|
||||||
|
两个反复踩到的坑都从这里堵:
|
||||||
|
|
||||||
|
- 验证跑着的时候改 `src/`,本轮结论作废,但报告已经按旧结论写出去;
|
||||||
|
- `make 格式写入` 也算改源码,改完没重新 `make 生成`,整层用例报「已安装源码与资源发布身份不符」,
|
||||||
|
几百个错误全是假的。
|
||||||
|
|
||||||
|
锁是 `fcntl.flock`,进程退出即释放,不靠残留 PID 文件判断;锁文件里记一句当前验证说明。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import fcntl
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
锁路径 = Path(os.environ.get("MUSE_验证锁", "/tmp/muse-验证锁.lock")) # noqa: S108
|
||||||
|
_持锁标记 = "MUSE_写入锁已持有"
|
||||||
|
|
||||||
|
|
||||||
|
def 正在验证() -> str | None:
|
||||||
|
"""返回当前验证说明;没有验证在跑时返回 None。
|
||||||
|
|
||||||
|
`--执行` 会在独占锁内跑写命令,被它启动的子进程(如构建编排)本身就是持锁方,
|
||||||
|
不能把自己判成「验证正在运行」;用环境变量把这一事实传给子进程。
|
||||||
|
"""
|
||||||
|
|
||||||
|
if os.environ.get(_持锁标记) == "1":
|
||||||
|
return None
|
||||||
|
if not 锁路径.exists():
|
||||||
|
return None
|
||||||
|
with 锁路径.open("a+", encoding="utf-8") as 文件:
|
||||||
|
try:
|
||||||
|
fcntl.flock(文件, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
return "有一个或多个验证会话在运行"
|
||||||
|
else:
|
||||||
|
fcntl.flock(文件, fcntl.LOCK_UN)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def 登记说明(说明: str) -> None:
|
||||||
|
"""把本次验证写进锁文件,供人工查看谁在跑。"""
|
||||||
|
|
||||||
|
with 锁路径.open("a+", encoding="utf-8") as 文件:
|
||||||
|
文件.write(f"{说明}\n")
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def 持有(说明: str) -> Iterator[None]:
|
||||||
|
"""验证会话全程持共享锁;写入方要求独占,故分片并行验证互不阻塞。"""
|
||||||
|
|
||||||
|
锁路径.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with 锁路径.open("a+", encoding="utf-8") as 文件:
|
||||||
|
fcntl.flock(文件, fcntl.LOCK_SH)
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
fcntl.flock(文件, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def 已安装代码哈希() -> str | None:
|
||||||
|
"""直接读安装清单里的代码哈希;不需要打开资源提供器。"""
|
||||||
|
|
||||||
|
清单 = Path(__file__).resolve().parents[1] / "src" / "muse" / "资源" / "清单.json"
|
||||||
|
if not 清单.is_file():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
数据: dict[str, Any] = json.loads(清单.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
return 数据.get("代码哈希")
|
||||||
|
|
||||||
|
|
||||||
|
def 源码身份核对() -> str | None:
|
||||||
|
"""返回不一致说明;一致返回 None。只算一次源码哈希,约 0.3 秒。"""
|
||||||
|
|
||||||
|
from muse.基础设施.系统运行 import 运行代码哈希
|
||||||
|
|
||||||
|
期望 = 已安装代码哈希()
|
||||||
|
if 期望 is None:
|
||||||
|
return "缺少已安装资源清单;先执行 make 生成"
|
||||||
|
当前 = 运行代码哈希()
|
||||||
|
if 当前 != 期望:
|
||||||
|
return "已安装源码与资源发布身份不符;先执行 make 生成(改过 src/ 或跑过格式写入都要重跑)"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def 独占执行(命令: list[str]) -> int:
|
||||||
|
"""在独占锁内执行写命令:写窗口全程持锁,验证会话无法插入。
|
||||||
|
|
||||||
|
与 `--要求空闲` 的区别:后者查完即放锁,检查与写入之间仍可被验证会话插入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
锁路径.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with 锁路径.open("a+", encoding="utf-8") as 文件:
|
||||||
|
try:
|
||||||
|
fcntl.flock(文件, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||||
|
except OSError:
|
||||||
|
print(
|
||||||
|
"拒绝改写:验证正在运行;等它结束再改,否则本轮验证结果作废",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
try:
|
||||||
|
os.environ[_持锁标记] = "1"
|
||||||
|
return subprocess.run(命令, check=False).returncode
|
||||||
|
finally:
|
||||||
|
os.environ.pop(_持锁标记, None)
|
||||||
|
fcntl.flock(文件, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
解析 = argparse.ArgumentParser(description=__doc__)
|
||||||
|
解析.add_argument(
|
||||||
|
"--要求空闲", action="store_true", help="锁被持有时非零退出,供写入入口前置检查(查完即放锁)"
|
||||||
|
)
|
||||||
|
解析.add_argument("--核对源码身份", action="store_true", help="比较源码哈希与安装清单")
|
||||||
|
解析.add_argument(
|
||||||
|
"--执行",
|
||||||
|
nargs=argparse.REMAINDER,
|
||||||
|
metavar="命令",
|
||||||
|
help="在独占锁内执行写命令,例如 --执行 uv run --no-sync python 工具/构建编排.py",
|
||||||
|
)
|
||||||
|
值 = 解析.parse_args(argv)
|
||||||
|
if 值.要求空闲:
|
||||||
|
说明 = 正在验证()
|
||||||
|
if 说明:
|
||||||
|
print(
|
||||||
|
f"拒绝改写:验证正在运行({说明});等它结束再改,否则本轮验证结果作废",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
print("验证空闲:可以改写源码")
|
||||||
|
if 值.核对源码身份:
|
||||||
|
问题 = 源码身份核对()
|
||||||
|
if 问题:
|
||||||
|
print(问题, file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print("源码身份一致:安装清单与当前源码同哈希")
|
||||||
|
if 值.执行 is not None:
|
||||||
|
if not 值.执行:
|
||||||
|
print("--执行 需要给出命令", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
return 独占执行(list(值.执行))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
x
Reference in New Issue
Block a user