chore: bootstrap YoVision DevHarness
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
## 背景
|
||||
|
||||
<!-- 为什么要做这个产品或长期能力。 -->
|
||||
|
||||
## 目标
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## 非目标
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## 总体方案
|
||||
|
||||
<!-- 只写稳定的架构和关键决策,不复制子任务细节。 -->
|
||||
|
||||
## 阶段路线
|
||||
|
||||
1. <!-- 填写 -->
|
||||
|
||||
## MVP 与任务索引
|
||||
|
||||
- [ ] # MVP 工单
|
||||
|
||||
## 依赖、风险和回退
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## 最终验收标准
|
||||
|
||||
- [ ] <!-- 填写 -->
|
||||
@@ -0,0 +1,27 @@
|
||||
## 基本信息
|
||||
|
||||
- 所属 Epic:#
|
||||
|
||||
## MVP 目标
|
||||
|
||||
<!-- 这个版本交付后,用户能够完成什么。 -->
|
||||
|
||||
## 包含范围
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## 排除范围
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## 阶段与单元任务
|
||||
|
||||
- [ ] # 单元任务
|
||||
|
||||
## 集成风险和回退
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## MVP 验收标准
|
||||
|
||||
- [ ] <!-- 填写 -->
|
||||
@@ -0,0 +1,87 @@
|
||||
## 基本信息
|
||||
|
||||
- 类型:需求 / 缺陷 / 重构
|
||||
- 所属 Epic:#
|
||||
- 所属 MVP / 版本:#
|
||||
- 阶段:
|
||||
|
||||
## 依赖与并行
|
||||
|
||||
- 前置工单:无 / #编号
|
||||
- 是否允许与前置工单并行:是 / 否
|
||||
- 原因:
|
||||
|
||||
## 子项目影响
|
||||
|
||||
<!-- 单应用项目填写唯一交付单元;多应用单仓库必须明确单端、跨端和共享契约影响。 -->
|
||||
|
||||
- 仅影响的子项目 / 交付单元:
|
||||
- 是否跨子项目:是 / 否
|
||||
- 是否修改共享接口或契约:是 / 否;唯一事实来源:
|
||||
- 各子项目需要执行的验证:
|
||||
|
||||
## 原始需求
|
||||
|
||||
- 来源:用户对话 / Gitea / 其他
|
||||
- 提出时间:
|
||||
- 关键原话或脱敏摘要:
|
||||
|
||||
<!-- 只保留表达用户目的、场景和限制所需的内容;不要复制完整聊天、内部推理或敏感信息。 -->
|
||||
|
||||
## 要解决什么
|
||||
|
||||
<!-- 描述现状和目标。缺陷需要写清复现步骤、实际结果和期望结果。 -->
|
||||
|
||||
## 做什么 / 不做什么
|
||||
|
||||
- 做:
|
||||
- 不做:
|
||||
|
||||
## 已确认方案
|
||||
|
||||
<!-- 写清修改范围、关键设计,以及是否影响接口、数据库和安全边界。 -->
|
||||
|
||||
预计修改文件:
|
||||
|
||||
- <!-- 填写 -->
|
||||
|
||||
## 需求变化记录
|
||||
|
||||
<!-- 只记录影响范围、接口、数据、风险或验收的变化;没有变化时填写“无”。 -->
|
||||
|
||||
| 日期 | 变化内容 | 原因 | 用户确认 |
|
||||
|---|---|---|---|
|
||||
| | | | 是 / 否 |
|
||||
|
||||
## 文档影响
|
||||
|
||||
<!-- 至少选择一项;不影响长期文档时必须写明原因。 -->
|
||||
|
||||
- [ ] 不影响长期文档,原因:
|
||||
- [ ] 更新项目档案或本地开发与验证
|
||||
- [ ] 更新架构与代码地图
|
||||
- [ ] 更新业务规则与术语
|
||||
- [ ] 更新常见修改或故障排查
|
||||
- [ ] 更新其他 Wiki 页面:
|
||||
|
||||
## 交付文档影响
|
||||
|
||||
<!-- 至少选择一项;面向用户、客户或其他岗位的行为、配置、部署、接口或支持方式变化时,必须列出受众和页面。 -->
|
||||
|
||||
- [ ] 无交付文档影响,原因:
|
||||
- [ ] 更新已有交付文档,受众与页面:
|
||||
- [ ] 新增交付文档,受众与页面:
|
||||
- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] <!-- 填写 -->
|
||||
- [ ] <!-- 填写 -->
|
||||
|
||||
## 验证方式
|
||||
|
||||
<!-- 写出可复制的命令;需要真机、生产环境或人工检查时明确说明。 -->
|
||||
|
||||
## 风险和回退
|
||||
|
||||
<!-- 普通低风险任务可删除本节;涉及接口、迁移、安全或不可逆操作时必填。 -->
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
.codex/gitea.env
|
||||
.codex/*.log
|
||||
.codex/config.toml
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
.venv/
|
||||
wiki_drafts/
|
||||
|
||||
# Local configuration and secrets
|
||||
*.env
|
||||
!.env.example
|
||||
*.local.*
|
||||
|
||||
# Build and test output
|
||||
coverage/
|
||||
dist/
|
||||
build/
|
||||
tmp/
|
||||
*.log
|
||||
@@ -0,0 +1,181 @@
|
||||
# Agent 开发规则
|
||||
|
||||
本仓库采用 DevHarness 工作流:Gitea 工单是任务过程的事实来源,Gitea Wiki 是长期开发文档和任务归档的事实来源,Git 是代码与版本绑定资料的变更记录,`docs/` 只保存 Wiki 的只读镜像。人负责确认方案与验收,Agent 负责检查、实现、测试和留下证据。
|
||||
|
||||
开始工作前先阅读 [项目档案](docs/00-project-profile.md) 和任务涉及目录中的 `AGENTS.md`。目录越深的规则越具体,但不得削弱上级安全规则。
|
||||
|
||||
## 1. 永久规则
|
||||
|
||||
- 不把密码、令牌、Cookie、私钥、个人数据或生产数据写入代码、日志、工单、Wiki 和文档。
|
||||
- 不执行未经用户明确授权的发布、付款、删除数据、破坏性迁移或其他不可逆操作。
|
||||
- 保留用户已有和任务无关的工作区改动,不擅自重置、覆盖或混入提交。
|
||||
- 发现需求与安全规则、已确认方案或现有数据冲突时,先停止实施并说明影响。
|
||||
- 测试结果必须真实;未执行或无法覆盖的验证必须明确记录。
|
||||
|
||||
项目专用红线写入本文件的“项目专用规则”或对应子目录的 `AGENTS.md`,不要散落在聊天记录中。
|
||||
|
||||
## 2. 哪些改动需要工单
|
||||
|
||||
新功能、缺陷修复、重构以及任何用户可感知或改变程序行为的修改,必须先建立单元任务工单。
|
||||
|
||||
以下小改动可以直接提交,不要求工单和任务归档:
|
||||
|
||||
- 只改错别字、注释或文档措辞;
|
||||
- 只做格式化、导入排序或不跨文件的内部变量改名;
|
||||
- 补充类型标注或文档字符串且不改变行为;
|
||||
- 删除已经确认无人使用的死代码。
|
||||
|
||||
只要涉及接口、数据库、状态、权限、安全、并发、用户界面,或者无法确定是否改变行为,就必须建工单。
|
||||
|
||||
## 3. 需求到实施
|
||||
|
||||
1. 先复述目标,阅读相关代码、日志和文档,区分事实与假设。
|
||||
2. 给出目标、非目标、方案、影响范围、风险、回退方式、验证方法和文档影响。
|
||||
3. 方案没有得到用户明确确认前,只做只读诊断和方案整理,不实施正式代码。
|
||||
4. 方案确认后,先建立单元任务工单,再修改代码。
|
||||
5. 建立新工单不要求其他工单已经完成;开始实施前检查工单声明的前置工单。真实依赖未满足时保持“待实施”,允许并行时必须写明原因。
|
||||
6. 开始实施前检查分支和工作区,明确哪些现有改动不属于本任务。
|
||||
7. 严格按工单范围实现;新发现的问题先记录,不顺手混入当前任务。
|
||||
8. 执行与风险相称的测试,把关键结果和未验证部分更新到工单。实施过程中出现计划外、当前无法解除的问题时才标记“阻塞”。
|
||||
9. 长期文档必须先修改 Wiki、读取确认,再运行 `python dev_scripts/sync_wiki_docs.py` 导出本地镜像;不得直接编辑 `docs/` 后反向覆盖 Wiki。
|
||||
|
||||
Gitea 不可用时,输出完整工单草稿并说明阻塞。未经用户明确授权,不得默认绕过建单。
|
||||
|
||||
### 自然语言快捷指令
|
||||
|
||||
快捷指令只是本工作流的自然语言别名,不得绕过方案确认、前置依赖、安全规则、工单范围、Wiki 主源、必要验证或人工验收:
|
||||
|
||||
- `只分析`:只读检查并给出方案;不建单、不修改,停在等待确认。
|
||||
- `建工单`:根据已确认方案创建单元任务工单;建单后停止,不修改代码。
|
||||
- `执行工单 #N`:检查工单和依赖,实施、测试、提交、归档、推送并回写证据;停在“待验收”。
|
||||
- `建工单并做`:依次建单和执行,`建工单,做`、`建工单,做` 含义相同;停在“待验收”。
|
||||
- `继续工单 #N`:核对工单、Git 和 Wiki 证据,从首个未完成步骤继续,不重复仍然有效的检查。
|
||||
- `检查工单 #N`:只读核对范围、验收、测试和证据并输出报告;不自动修复。
|
||||
- `同步文档`:读取 Wiki、导出 `docs/` 并检查一致性;不修改 Wiki、不自动提交。
|
||||
- `#N 验收通过`:仅在用户明确验收后,更新归档、同步并提交镜像、推送、同步父工单并关闭任务。
|
||||
|
||||
方案未确认或前置依赖未满足时,实施类指令必须停在对应门禁;除 `#N 验收通过` 外,快捷指令不得关闭待验收工单。Gitea 工单不导出全文,本地只保存 Wiki 任务归档镜像。详细语义见 [开发工作流](docs/01-workflow.md)。
|
||||
|
||||
### 需求记录与流转
|
||||
|
||||
- 创建单元任务工单时,记录原始需求的来源、提出时间,以及能表达用户目的、场景和限制的少量关键原话或脱敏摘要;不得臆造用户原话。
|
||||
- 工单中的目标、非目标、已确认方案、验收标准和文档影响构成确认后的正式任务需求。
|
||||
- 影响范围、接口、数据、风险或验收的需求变化必须记录日期、内容、原因和用户确认;会改变已确认结果时先更新工单并等待再次确认。
|
||||
- 不复制完整聊天,不保存 Agent 内部推理,不写入密码、令牌、个人数据或生产数据;包含敏感信息的原话必须删除敏感部分或改写为脱敏摘要。
|
||||
- 长期有效的产品需求、业务规则和系统边界进入对应 Wiki 主题页并导出 `docs/`;完成结果进入 Wiki 任务归档并导出 `docs/task/`。Gitea 工单全文不导出到仓库。
|
||||
|
||||
详细记录边界见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。
|
||||
|
||||
### 效率与范围控制
|
||||
|
||||
本节只用于减少无关工作和重复检查,不得削弱安全规则、已确认方案、工单范围、必要测试、Wiki 同步、Git 提交和验收归档要求。
|
||||
|
||||
#### 严格控制范围
|
||||
|
||||
- 默认严格按用户确认的目标和单元任务范围执行,不主动扩展相邻问题。
|
||||
- 除非任务目标、仓库强制规则或已发现的真实阻塞需要,不新增额外文档、辅助脚本、备份文件、框架、重构或扩展性设计。
|
||||
- 不执行与本次验收无关的验证;安全检查、受影响范围测试、回归测试和仓库规定的闭环验证不属于“额外验证”。
|
||||
- 新发现的相邻问题最多用一句话提示或记录到独立工单,不自动修复或混入当前提交。
|
||||
|
||||
#### 渐进执行和修复
|
||||
|
||||
- 完成已知必要的安全与前置检查后,优先执行能够产生真实反馈的最小命令。
|
||||
- 一次执行后先处理首个可定位、可行动的真实错误,不同时猜测并修改多个可能原因。
|
||||
- 采用“执行 → 查看错误 → 最小修复 → 从失败点继续或按需重跑”的闭环。
|
||||
- 不在真实证据出现前堆叠与已知风险无关的预防性检查。
|
||||
- 涉及凭据、权限、安全、数据、迁移、并发、删除、发布或不可逆操作时,必须先完成相应前置检查,不得通过试错获取风险反馈。
|
||||
|
||||
#### 复用已验证事实
|
||||
|
||||
- 在同一任务和同一环境状态下,已经通过的路由、连接、恢复和环境检查不重复执行。
|
||||
- 只有会话、环境、代码、配置、依赖、凭据、远端状态或关键前提发生变化时才重新检查。
|
||||
- 代码修改后,受影响测试和最终验收必须重新执行;提交前工作区检查、推送前远端分支检查不得因为之前通过而省略。
|
||||
- Skill 和平台规则是否需要重新读取,按当前 Agent 平台和任务触发规则执行,不自行跳过。
|
||||
|
||||
#### 明确停止条件
|
||||
|
||||
- 完成用户确认的验收标准和仓库规定的必要闭环后立即停止,不主动继续优化。
|
||||
- “最小验收条件”包括当前工单要求的实现、必要测试、文档影响处理、Wiki 镜像检查、提交和证据回写,不等同于功能第一次运行成功。
|
||||
- 未影响当前验收的相邻问题只提示或建单,不顺手处理。
|
||||
|
||||
## 4. 工单层级
|
||||
|
||||
- 单元任务是唯一正式实施单位,记录方案、范围、验收标准、过程和测试结果。
|
||||
- Epic 和 MVP 只维护目标、风险、汇总及 `- [ ] #编号` / `- [x] #编号` 子工单索引,不复制单元任务全文。
|
||||
- 新任务先建单元工单,再把编号同步到所属 MVP 和 Epic。
|
||||
- 详细层级、状态和模板入口见 [开发工作流](docs/01-workflow.md) 与 [业务规则和术语](docs/03-business-rules-and-glossary.md)。
|
||||
|
||||
## 5. 实施中的变化
|
||||
|
||||
- 范围、接口、数据结构、依赖、验收标准或风险变化时,先更新单元工单。
|
||||
- 变化影响 MVP 或 Epic 时,同时更新父工单。
|
||||
- 会改变用户已确认结果的变化,更新工单后必须再次等待用户确认。
|
||||
- 阻塞、失败方案和新发现根因不能只留在聊天或代码注释中。
|
||||
- Wiki 页面删除、重命名或事实源边界变化时,必须先更新工单并等待用户确认;同步工具不得自动传播删除或重命名。
|
||||
- 工单状态应使用:待确认、待实施、进行中、阻塞、待验收、已完成。
|
||||
|
||||
## 6. Git 与验证
|
||||
|
||||
- 提交只包含当前工单相关文件。
|
||||
- 实现提交信息引用工单号,例如:`fix: 修复登录超时 (#123)`。
|
||||
- 不为流程制造空提交。
|
||||
- 优先运行项目档案中记录的格式检查、静态检查、单元测试和必要的集成测试。
|
||||
- 不能验证的真机、生产、迁移或并发行为必须写入工单和归档。
|
||||
|
||||
## 7. 完成、验收和归档
|
||||
|
||||
1. 逐项完成验收、测试和实现提交,并把最终方案、差异、结果、提交及遗留问题写回工单。
|
||||
2. 工单保持“待验收”,用户没有明确验收通过前不得关闭。
|
||||
3. 运行 `python dev_scripts/new_task_archive.py <编号> "<短标题>"`,先创建 Wiki 归档,再登记并导出本地镜像。
|
||||
4. 读取确认 Wiki,运行 `python dev_scripts/sync_wiki_docs.py --check`;归档镜像单独提交,并把页面、revision、路径和提交哈希写回工单。
|
||||
5. 用户验收通过后关闭单元工单,并同步更新 MVP 和 Epic。
|
||||
|
||||
MVP 内所有单元任务通过后才能做 MVP 集成验收;MVP 通过后才能关闭 MVP。Epic 的全部范围完成后才能关闭 Epic。
|
||||
|
||||
详细归档顺序和字段见 [开发工作流](docs/01-workflow.md)。
|
||||
|
||||
## 8. 可维护性
|
||||
|
||||
- 优先使用直白、常见的实现;不要为了少写几行引入晦涩技巧。
|
||||
- 类和函数保持单一职责,名称表达业务含义。
|
||||
- 注释解释原因、边界和风险,不逐行翻译代码。
|
||||
- 错误必须可定位,不静默吞掉失败。
|
||||
- Wiki 文档先写结论和用途,再写步骤;示例命令应可直接复制,本地 `docs/` 由同步工具生成。
|
||||
- 面向初级维护者说明从哪里开始读、怎样运行和怎样验证。
|
||||
|
||||
### 修改风险
|
||||
|
||||
- 低风险修改可由初级程序员在 Agent 协助下处理;接口、配置、依赖、跨模块逻辑和数据结构由 Agent 实现并验证。
|
||||
- 权限、安全、并发、迁移、支付、删除数据和不可逆操作属于高风险;高风险修改必须停止,由 Agent 分析并等待人工确认。
|
||||
- 风险按影响范围判断,不按代码行数判断;示例见 [常见修改指南](docs/05-common-changes.md)。
|
||||
|
||||
### 文档影响
|
||||
|
||||
- 每个单元任务必须在工单中选择“无长期文档影响并说明原因”或列出需要更新的 Wiki 页面。
|
||||
- 启动、测试、部署、排错命令,模块入口、目录职责、主要调用路径,配置、API、数据结构、状态、业务规则、安全边界、日志位置发生变化时,必须更新对应 Wiki。
|
||||
- 普通内部重构只有在入口、行为、配置和验证方式均未改变时,才可以记录为不影响长期文档。
|
||||
- 必需核心页面及结构以 `python dev_scripts/check_harness.py --strict` 和 [新项目文档初始化](docs/07-new-project-documentation-setup.md) 为准;稳定文档与任务归档的分工见 [开发工作流](docs/01-workflow.md)。
|
||||
|
||||
## 9. 引导提交例外
|
||||
|
||||
从本模板创建全新仓库时,Gitea 远端和工单尚不存在,允许一次不带工单号的初始引导提交。该提交只能包含仓库骨架、Harness 规则和远端配置准备,不能包含产品功能。
|
||||
|
||||
远端建立并推送后,这个例外立即失效。
|
||||
|
||||
## 10. 项目专用规则
|
||||
|
||||
<!-- 在项目初始化时填写不可违反的技术、安全和业务约束。复杂子项目请在其目录中增加 AGENTS.md。 -->
|
||||
|
||||
- 长期开发文档以 Gitea Wiki 为事实来源,`docs/` 是显式映射生成的只读镜像。
|
||||
- Wiki 与镜像的固定顺序是:修改 Wiki → 读取确认 → 导出 `docs` → 校验差异 → 提交镜像。
|
||||
- 同步配置只允许写入 `docs/` 下的 Markdown;发现镜像有未提交修改时必须停止。
|
||||
- Gitea 凭据只通过进程环境或 MCP 安全配置提供,不得写入仓库。
|
||||
- `dev_scripts/` 只存放 DevHarness 自身工具;业务项目的通用脚本必须使用独立目录,不得混放。
|
||||
- 本仓库包含 `Sense/`、`Brain/`、`Bell/` 三个独立开发与交付单元;Sense 与 Bell 是认证、数据、部署和发布完全独立的销售产品,Brain 是无界面推理交付单元。
|
||||
- 三个主 agent 默认分别只写自己的子项目目录;任务必须声明主 agent、子项目影响和精确 `write_paths`。
|
||||
- `contracts/`、根级构建/部署配置和端到端测试属于共享写路径,只能由明确的协调工单和单一协调 agent 修改。
|
||||
- 同一路径以及父子目录视为冲突;发现重叠时停止写入,不依靠事后合并解决并发所有权。
|
||||
- 跨项目只共享版本化协议,不共享用户表、JWT、Cookie、数据库内部模型、摄像头凭据或业务实现代码。
|
||||
- 旧仓库 `D:\OPC\yovision_old` 只读用于需求和证据追溯;不得继承旧任务状态,也不得不经筛选整树复制代码。
|
||||
- 16 路是默认交付配额,不得成为数据库、数组、循环、分页、批处理或单机容量的硬上限。
|
||||
- 开始子项目工作前还必须读取对应目录的 `AGENTS.md`;共享契约工作读取 `contracts/AGENTS.md`。
|
||||
@@ -0,0 +1,9 @@
|
||||
# Bell 目录规则
|
||||
|
||||
- Bell 是独立销售和部署的事件预警与处置产品。
|
||||
- 主实现技术栈为 Go 后端与 Vue 3 + Element Plus 管理端;正式数据库为 PostgreSQL。
|
||||
- Bell 拥有独立账户、角色、会话、数据库角色、审计和密码策略,不信任 Sense 用户 token。
|
||||
- Event 与 Alert 分离;原始 Event 不可变,Alert 状态迁移、ack/close、投递和升级事实只追加并可审计。
|
||||
- 外部事件以 `(producer_id, source_event_id)` 幂等;Bell 不读取 Sense 数据库、摄像头密码或内部文件路径。
|
||||
- 修改 `contracts/`、Sense connector 或 Brain 输出语义前,必须转为跨项目协调工单。
|
||||
- Bell agent 默认写路径仅为 `Bell/` 及工单明确列出的共享文件。
|
||||
@@ -0,0 +1,9 @@
|
||||
# Brain 目录规则
|
||||
|
||||
- Brain 是无界面的独立 Python/CUDA 推理交付单元,默认随 Sense 产品部署。
|
||||
- Brain 负责解码、推理、跟踪、ReID、区域/警戒线判定和标准事件映射;不持有账户、RBAC、Alert 或通知状态。
|
||||
- 业务上保持无状态;模型、缓存和 GPU 运行态不得成为事件或告警的唯一事实来源。
|
||||
- 输出必须符合 `contracts/` 的冻结事件契约;不得把摄像头密码、内部文件路径或未经授权的人脸信息写入事件。
|
||||
- 模型与规则分层:模型输出观测,版本化规则作业务判定。
|
||||
- 修改 Sense 源接口、Bell 入站接口或共享契约前,必须转为跨项目协调工单。
|
||||
- Brain agent 默认写路径仅为 `Brain/` 及工单明确列出的共享文件。
|
||||
@@ -0,0 +1,31 @@
|
||||
# Claude Code 项目入口
|
||||
|
||||
@AGENTS.md
|
||||
|
||||
## Claude Code 专用说明
|
||||
|
||||
- 上方导入的 `AGENTS.md` 是所有编码 Agent 的共同规则事实来源,Claude Code 必须完整遵守。
|
||||
- 本文件只记录 Claude Code 特有的模型路由、工具和 Agent 协作规则。
|
||||
- 共同规则变化时只修改 `AGENTS.md`,不要在本文件重复维护。
|
||||
|
||||
## 模型路由
|
||||
|
||||
- 目标、范围和修改位置已经明确时,默认使用 Sonnet 分析、建单和实施。
|
||||
- 需求模糊、根因不明,或涉及跨模块架构、安全、权限、并发、迁移和不可逆操作时,使用 Opus 或 `opusplan` 制定方案。
|
||||
- Opus 输出方案后必须等待用户确认;确认后由 Sonnet 根据方案创建工单并实施。
|
||||
- Haiku 只用于范围明确的只读任务,例如查找代码入口、读取项目文档、提取日志事实和整理调用关系。
|
||||
- 不让 Haiku 决定最终根因、技术方案、风险等级或验收结论。
|
||||
- 当前模型足以完成任务时不升级模型,也不为了形式固定依次调用三个模型。
|
||||
|
||||
## Agent 交接
|
||||
|
||||
- Opus 向 Sonnet 交接目标、非目标、事实、假设、方案、修改范围、风险、回退方式和验收标准。
|
||||
- Haiku 向主 Agent 交接结论、证据位置和仍不确定的内容,不返回与任务无关的大段原文。
|
||||
- Sonnet 严格按照已确认方案和工单实施;发现范围变化时返回主流程重新确认。
|
||||
- 不同 Agent 复用仍然有效的检查结果;会话、环境或关键前提变化时才重新检查。
|
||||
|
||||
## Haiku 只读约束
|
||||
|
||||
- Haiku 子 Agent 只允许使用读取、搜索和只读代码图谱工具。
|
||||
- 禁止 Haiku 修改文件、创建或更新工单、提交 Git、更新 Wiki 或执行具有副作用的命令。
|
||||
- 只读必须通过子 Agent 工具权限实现,不能只依赖提示语;未配置只读权限时不得调用 Haiku 处理项目内容。
|
||||
@@ -0,0 +1,11 @@
|
||||
# YoVision
|
||||
|
||||
YoVision 是一个单仓多项目的智能视频事件平台,包含三个可独立开发和交付的子项目:
|
||||
|
||||
- `Sense/`:设备、ONVIF/RTSP、MediaMTX、实时监看和边缘运维;
|
||||
- `Brain/`:解码、推理、跟踪、区域判定和标准事件生成;
|
||||
- `Bell/`:事件接入、规则、Alert、ack/close、升级和通知。
|
||||
|
||||
Sense 与 Bell 是账户、数据、部署和发布边界完全独立的两个销售产品;Brain 是无界面的独立推理交付单元,默认随 Sense 部署。跨项目协作只通过 `contracts/` 中的版本化契约。
|
||||
|
||||
开始工作前阅读 [AGENTS.md](AGENTS.md) 和 [文档中心](docs/README.md)。本仓库采用 DevHarness:Gitea 工单记录任务过程,Gitea Wiki 保存长期文档,`docs/` 是 Wiki 的只读镜像。
|
||||
@@ -0,0 +1,9 @@
|
||||
# Sense 目录规则
|
||||
|
||||
- Sense 是独立销售和部署的智能 NVR/视频感知产品管理面。
|
||||
- 主实现技术栈为 Go 后端与 Vue 3 + Element Plus 管理端;正式数据库为 PostgreSQL。
|
||||
- MediaMTX 保持独立二进制,Sense 只管理配置、生命周期与对账,不把媒体内核写进 HTTP handler 或 ORM model。
|
||||
- Sense 不读取 Bell 数据库、不接受 Bell 用户会话,也不依赖 Bell 才能启动。
|
||||
- 摄像头凭据只能从仓库外安全配置读取,日志、工单、测试夹具和 URL 不得包含明文凭据。
|
||||
- 修改 `contracts/`、Brain/Bell 消费接口或根级部署文件前,必须转为跨项目协调工单。
|
||||
- Sense agent 默认写路径仅为 `Sense/` 及工单明确列出的共享文件。
|
||||
@@ -0,0 +1,7 @@
|
||||
# 共享契约目录规则
|
||||
|
||||
- `contracts/` 是跨 Sense、Brain、Bell 接口的唯一事实来源。
|
||||
- 只保存版本化 OpenAPI、JSON Schema、SQL 契约、示例和兼容说明,不保存共享业务实现。
|
||||
- 任一变更必须由跨项目协调工单负责,明确生产者、消费者、兼容周期、迁移和回退。
|
||||
- 破坏性变更必须发布新版本;不得原地改变已经发布的字段语义。
|
||||
- 每次变更必须验证所有受影响的生产者与消费者。
|
||||
@@ -0,0 +1,384 @@
|
||||
"""检查 DevHarness 必需文件、核心文档和任务归档的基本结构。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from wiki_docs import WikiDocsError, load_config, parse_mirror
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CORE_PAGE_PATHS = {
|
||||
"Home": "docs/README.md",
|
||||
"Project-Profile": "docs/00-project-profile.md",
|
||||
"Development-Workflow": "docs/01-workflow.md",
|
||||
"Architecture-and-Code-Map": "docs/02-architecture-and-code-map.md",
|
||||
"Business-Rules-and-Glossary": "docs/03-business-rules-and-glossary.md",
|
||||
"Local-Development-and-Verification": (
|
||||
"docs/04-local-development-and-verification.md"
|
||||
),
|
||||
"Common-Changes": "docs/05-common-changes.md",
|
||||
"Troubleshooting": "docs/06-troubleshooting.md",
|
||||
"New-Project-Documentation-Setup": (
|
||||
"docs/07-new-project-documentation-setup.md"
|
||||
),
|
||||
"Existing-Project-Adoption-Guide": (
|
||||
"docs/08-existing-project-adoption.md"
|
||||
),
|
||||
"Delivery-Documentation-Guide": "docs/delivery/README.md",
|
||||
"Audience-Document-Template": (
|
||||
"docs/delivery/audience-document-template.md"
|
||||
),
|
||||
"Task-Archive-Template": "docs/templates/task-archive.md",
|
||||
}
|
||||
CORE_DOCUMENT_REQUIREMENTS = {
|
||||
"docs/README.md": (
|
||||
"## 第一次阅读",
|
||||
"## 五分钟开始",
|
||||
"## 简单修改从哪里开始",
|
||||
"## 事实来源",
|
||||
),
|
||||
"docs/00-project-profile.md": (
|
||||
"## 基本信息",
|
||||
"## 子项目与交付单元",
|
||||
"## 技术栈与运行环境",
|
||||
"## 阅读入口",
|
||||
"## 常用命令",
|
||||
"## 环境、配置与凭据",
|
||||
),
|
||||
"docs/01-workflow.md": (
|
||||
"## 面向初级维护者的修改边界",
|
||||
"## 每个任务的文档影响",
|
||||
"## 需求记录与流转",
|
||||
"## 稳定文档与任务归档",
|
||||
"## 自然语言快捷指令",
|
||||
"## 效率与范围控制",
|
||||
"### 严格控制范围",
|
||||
"### 渐进执行和修复",
|
||||
"### 复用已验证事实",
|
||||
"### 明确停止条件",
|
||||
),
|
||||
"docs/02-architecture-and-code-map.md": (
|
||||
"## 项目定位",
|
||||
"## 代码地图",
|
||||
"## 两条主要执行路径",
|
||||
"## 不可破坏的边界",
|
||||
),
|
||||
"docs/03-business-rules-and-glossary.md": (
|
||||
"## 核心术语",
|
||||
"## 工单状态",
|
||||
"## 稳定业务规则",
|
||||
"## 新项目需要补充什么",
|
||||
),
|
||||
"docs/04-local-development-and-verification.md": (
|
||||
"## 环境要求",
|
||||
"## 第一次运行",
|
||||
"## 常用调试方式",
|
||||
"## 完成修改前",
|
||||
),
|
||||
"docs/05-common-changes.md": (
|
||||
"## 风险分级",
|
||||
"## 修改 Wiki 文案",
|
||||
"## 调整 Harness 检查",
|
||||
"## 看懂 Agent 的修改",
|
||||
),
|
||||
"docs/06-troubleshooting.md": (
|
||||
"## 排查顺序",
|
||||
"## 必须停止的情况",
|
||||
),
|
||||
"docs/07-new-project-documentation-setup.md": (
|
||||
"## 初始化顺序",
|
||||
"### 2. 识别子项目与交付单元",
|
||||
"### 6. 确定交付对象和文档",
|
||||
"## 完成标准",
|
||||
),
|
||||
"docs/08-existing-project-adoption.md": (
|
||||
"## 与新项目初始化的区别",
|
||||
"## 接入前只读盘点",
|
||||
"## 已有内容保护原则",
|
||||
"## 多应用单仓库判断",
|
||||
"### 适合继续单仓库",
|
||||
"### 可以考虑拆仓",
|
||||
"### 保持单仓库时的最小规则",
|
||||
"## 增量接入顺序",
|
||||
"## 冲突处理和停止条件",
|
||||
"## 可复制 Agent 指令",
|
||||
"### 只分析",
|
||||
"### 方案确认后实施",
|
||||
"## 最小验收清单",
|
||||
"## 回退原则",
|
||||
),
|
||||
"docs/delivery/README.md": (
|
||||
"## 什么时候需要交付文档",
|
||||
"## 受众与文档选择",
|
||||
"## 内部文档与交付文档边界",
|
||||
"## 编写和维护流程",
|
||||
"## 最小验收清单",
|
||||
),
|
||||
"docs/delivery/audience-document-template.md": (
|
||||
"## 文档信息",
|
||||
"## 目的与适用范围",
|
||||
"## 前置条件",
|
||||
"## 操作步骤",
|
||||
"## 常见错误与恢复",
|
||||
"## 安全与权限",
|
||||
"## 已知限制",
|
||||
"## 支持与升级处理",
|
||||
"## 版本记录",
|
||||
"## 交付前检查",
|
||||
),
|
||||
}
|
||||
REQUIRED_FILES = (
|
||||
"AGENTS.md",
|
||||
"CLAUDE.md",
|
||||
"README.md",
|
||||
"docs/00-project-profile.md",
|
||||
"docs/01-workflow.md",
|
||||
"docs/templates/task-archive.md",
|
||||
*CORE_DOCUMENT_REQUIREMENTS,
|
||||
"wiki-docs.json",
|
||||
"dev_scripts/wiki_docs.py",
|
||||
"dev_scripts/sync_wiki_docs.py",
|
||||
".gitea/issue_template/epic.md",
|
||||
".gitea/issue_template/mvp.md",
|
||||
".gitea/issue_template/task.md",
|
||||
)
|
||||
ARCHIVE_HEADINGS = (
|
||||
"## 背景与目标",
|
||||
"## 最终方案",
|
||||
"## 修改文件",
|
||||
"## 验收结果",
|
||||
"## 测试",
|
||||
"## 相关提交",
|
||||
)
|
||||
|
||||
|
||||
def check_required_files(errors: list[str]) -> None:
|
||||
for relative_path in REQUIRED_FILES:
|
||||
if not (ROOT / relative_path).is_file():
|
||||
errors.append(f"缺少必需文件:{relative_path}")
|
||||
|
||||
|
||||
def check_project_profile(errors: list[str], warnings: list[str], strict: bool) -> None:
|
||||
profile = ROOT / "docs" / "00-project-profile.md"
|
||||
if not profile.is_file():
|
||||
return
|
||||
if "<填写" in profile.read_text(encoding="utf-8"):
|
||||
message = "项目档案仍有未填写内容"
|
||||
(errors if strict else warnings).append(message)
|
||||
|
||||
|
||||
def check_archives(errors: list[str]) -> None:
|
||||
task_dir = ROOT / "docs" / "task"
|
||||
for path in task_dir.glob("*.md"):
|
||||
if not re.match(r"^\d+-.+\.md$", path.name):
|
||||
errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
for heading in ARCHIVE_HEADINGS:
|
||||
if heading not in content:
|
||||
errors.append(f"{path.name} 缺少章节:{heading}")
|
||||
if "**未验证部分**:" not in content:
|
||||
errors.append(f"{path.name} 没有记录未验证部分")
|
||||
|
||||
|
||||
def missing_sections(content: str, required: tuple[str, ...]) -> list[str]:
|
||||
return [section for section in required if section not in content]
|
||||
|
||||
|
||||
def check_core_documents(errors: list[str], root: Path = ROOT) -> None:
|
||||
"""检查初级维护者所需主题页的固定结构。"""
|
||||
|
||||
for relative_path, required in CORE_DOCUMENT_REQUIREMENTS.items():
|
||||
path = root / relative_path
|
||||
if not path.is_file():
|
||||
continue
|
||||
try:
|
||||
_, body = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError):
|
||||
continue
|
||||
for section in missing_sections(body, required):
|
||||
errors.append(f"{relative_path} 缺少核心章节:{section}")
|
||||
|
||||
|
||||
def check_task_template(errors: list[str], root: Path = ROOT) -> None:
|
||||
path = root / ".gitea" / "issue_template" / "task.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
required = (
|
||||
"## 依赖与并行",
|
||||
"- 前置工单:无 / #编号",
|
||||
"- 是否允许与前置工单并行:是 / 否",
|
||||
"- 原因:",
|
||||
"## 子项目影响",
|
||||
"- 仅影响的子项目 / 交付单元:",
|
||||
"- 是否跨子项目:是 / 否",
|
||||
"- 是否修改共享接口或契约:是 / 否;唯一事实来源:",
|
||||
"- 各子项目需要执行的验证:",
|
||||
"## 原始需求",
|
||||
"- 来源:用户对话 / Gitea / 其他",
|
||||
"- 提出时间:",
|
||||
"- 关键原话或脱敏摘要:",
|
||||
"## 需求变化记录",
|
||||
"| 日期 | 变化内容 | 原因 | 用户确认 |",
|
||||
"## 文档影响",
|
||||
"- [ ] 不影响长期文档,原因:",
|
||||
"- [ ] 更新架构与代码地图",
|
||||
"- [ ] 更新业务规则与术语",
|
||||
"- [ ] 更新常见修改或故障排查",
|
||||
"## 交付文档影响",
|
||||
"- [ ] 无交付文档影响,原因:",
|
||||
"- [ ] 更新已有交付文档,受众与页面:",
|
||||
"- [ ] 新增交付文档,受众与页面:",
|
||||
"- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"单元任务模板缺少:{section}")
|
||||
|
||||
|
||||
def check_agent_efficiency_rules(errors: list[str], root: Path = ROOT) -> None:
|
||||
path = root / "AGENTS.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
required = (
|
||||
"### 效率与范围控制",
|
||||
"#### 严格控制范围",
|
||||
"#### 渐进执行和修复",
|
||||
"#### 复用已验证事实",
|
||||
"#### 明确停止条件",
|
||||
"单元任务是唯一正式实施单位",
|
||||
"高风险修改必须停止",
|
||||
"用户没有明确验收通过前不得关闭",
|
||||
"长期文档必须先修改 Wiki",
|
||||
"提交只包含当前工单相关文件",
|
||||
"### 自然语言快捷指令",
|
||||
"`只分析`",
|
||||
"`建工单`",
|
||||
"`执行工单 #N`",
|
||||
"`建工单并做`",
|
||||
"`继续工单 #N`",
|
||||
"`检查工单 #N`",
|
||||
"`同步文档`",
|
||||
"`#N 验收通过`",
|
||||
"### 需求记录与流转",
|
||||
"不得臆造用户原话",
|
||||
"不复制完整聊天",
|
||||
"Gitea 工单全文不导出到仓库",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"AGENTS.md 缺少:{section}")
|
||||
|
||||
|
||||
def check_claude_code_entry(errors: list[str], root: Path = ROOT) -> None:
|
||||
"""检查 Claude Code 入口直接复用共同 Agent 规则。"""
|
||||
|
||||
path = root / "CLAUDE.md"
|
||||
if not path.is_file():
|
||||
return
|
||||
content = path.read_text(encoding="utf-8")
|
||||
lines = {line.strip() for line in content.splitlines()}
|
||||
if "@AGENTS.md" not in lines:
|
||||
errors.append("CLAUDE.md 缺少独立的 @AGENTS.md 导入")
|
||||
required = (
|
||||
"共同规则事实来源",
|
||||
"只记录 Claude Code 特有",
|
||||
"只修改 `AGENTS.md`",
|
||||
"## 模型路由",
|
||||
"## Agent 交接",
|
||||
"## Haiku 只读约束",
|
||||
"当前模型足以完成任务时不升级模型",
|
||||
"Opus 输出方案后必须等待用户确认",
|
||||
"不让 Haiku 决定最终根因",
|
||||
"只读必须通过子 Agent 工具权限实现",
|
||||
)
|
||||
for section in missing_sections(content, required):
|
||||
errors.append(f"CLAUDE.md 缺少:{section}")
|
||||
|
||||
|
||||
def core_mapping_errors(configured_mappings: dict[str, str]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
for page, expected_path in CORE_PAGE_PATHS.items():
|
||||
if configured_mappings.get(page) != expected_path:
|
||||
errors.append(
|
||||
f"核心 Wiki 页面映射缺失或路径错误:{page} -> {expected_path}"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
def check_wiki_mirrors(errors: list[str]) -> None:
|
||||
"""检查每份本地文档都有显式映射和可追踪的镜像头。"""
|
||||
|
||||
try:
|
||||
config = load_config()
|
||||
except WikiDocsError as exc:
|
||||
errors.append(str(exc))
|
||||
return
|
||||
|
||||
configured_mappings = {mapping.page: mapping.path for mapping in config.mappings}
|
||||
errors.extend(core_mapping_errors(configured_mappings))
|
||||
|
||||
mapped_paths = {mapping.path for mapping in config.mappings}
|
||||
actual_paths = {
|
||||
path.relative_to(ROOT).as_posix() for path in (ROOT / "docs").rglob("*.md")
|
||||
}
|
||||
for path in sorted(actual_paths - mapped_paths):
|
||||
errors.append(f"docs 中存在未登记的 Wiki 镜像:{path}")
|
||||
|
||||
for mapping in config.mappings:
|
||||
path = ROOT / mapping.path
|
||||
if not path.is_file():
|
||||
errors.append(f"缺少 Wiki 镜像:{mapping.path}")
|
||||
continue
|
||||
try:
|
||||
metadata, _ = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
|
||||
errors.append(f"Wiki 镜像无效 {mapping.path}:{exc}")
|
||||
continue
|
||||
if metadata.get("generated") != "true (请先修改 Gitea Wiki,禁止直接编辑本文件)":
|
||||
errors.append(f"{mapping.path} 没有只读镜像标记")
|
||||
if metadata.get("wiki_page") != mapping.page:
|
||||
errors.append(f"{mapping.path} 的 wiki_page 与映射不一致")
|
||||
revision = metadata.get("wiki_revision", "")
|
||||
if re.fullmatch(r"[0-9a-f]{40,64}", revision) is None:
|
||||
errors.append(f"{mapping.path} 的 wiki_revision 无效")
|
||||
if not metadata.get("synchronized_at"):
|
||||
errors.append(f"{mapping.path} 缺少 synchronized_at")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="检查 DevHarness 项目结构")
|
||||
parser.add_argument(
|
||||
"--strict",
|
||||
action="store_true",
|
||||
help="项目档案有占位内容时返回失败",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
check_required_files(errors)
|
||||
check_project_profile(errors, warnings, args.strict)
|
||||
check_wiki_mirrors(errors)
|
||||
check_core_documents(errors)
|
||||
check_task_template(errors)
|
||||
check_agent_efficiency_rules(errors)
|
||||
check_claude_code_entry(errors)
|
||||
check_archives(errors)
|
||||
|
||||
for warning in warnings:
|
||||
print(f"警告:{warning}")
|
||||
for error in errors:
|
||||
print(f"错误:{error}")
|
||||
|
||||
if errors:
|
||||
print(f"检查失败:{len(errors)} 个问题")
|
||||
return 1
|
||||
print("DevHarness 检查通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,101 @@
|
||||
"""先在 Gitea Wiki 创建任务归档,再登记并导出本地镜像。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import re
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from wiki_docs import (
|
||||
DEFAULT_CONFIG,
|
||||
Mapping,
|
||||
WikiClient,
|
||||
WikiDocsError,
|
||||
append_mapping,
|
||||
load_config,
|
||||
sync_all,
|
||||
)
|
||||
|
||||
|
||||
def safe_title(title: str) -> str:
|
||||
"""把标题转换为适合 Wiki 页面名和 Windows 文件名的短文本。"""
|
||||
|
||||
cleaned = re.sub(r'[<>:"/\\|?*]', "-", title.strip())
|
||||
cleaned = re.sub(r"\s+", "-", cleaned)
|
||||
cleaned = re.sub(r"-+", "-", cleaned)
|
||||
return cleaned.strip(".-")
|
||||
|
||||
|
||||
def build_archive(
|
||||
template: str,
|
||||
issue_number: str,
|
||||
title: str,
|
||||
page_name: str,
|
||||
issue_url: str,
|
||||
) -> str:
|
||||
content = template.replace("<工单号>", issue_number, 1)
|
||||
content = content.replace("<标题>", title.strip(), 1)
|
||||
content = content.replace("YYYY-MM-DD", date.today().isoformat(), 1)
|
||||
content = content.replace("<链接>", issue_url, 1)
|
||||
return content.replace("<页面名>", page_name, 1)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="在 Gitea Wiki 创建任务归档并导出 docs/task 镜像"
|
||||
)
|
||||
parser.add_argument("issue_number", help="Gitea 工单号,例如 123")
|
||||
parser.add_argument("title", help="简短任务标题")
|
||||
parser.add_argument("--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置")
|
||||
args = parser.parse_args()
|
||||
|
||||
short_title = safe_title(args.title)
|
||||
if not args.issue_number.isdigit():
|
||||
print("错误:工单号必须是数字")
|
||||
return 1
|
||||
if not short_title:
|
||||
print("错误:标题不能为空")
|
||||
return 1
|
||||
|
||||
try:
|
||||
config = load_config(Path(args.config).resolve())
|
||||
page_name = f"Task-{args.issue_number}-{short_title}"
|
||||
local_path = f"docs/task/{args.issue_number}-{short_title}.md"
|
||||
mapping = Mapping(page=page_name, path=local_path)
|
||||
if any(
|
||||
item.page == mapping.page or item.path == mapping.path
|
||||
for item in config.mappings
|
||||
):
|
||||
raise WikiDocsError(f"任务归档已经登记:{page_name}")
|
||||
|
||||
client = WikiClient(config)
|
||||
template = client.get_page("Task-Archive-Template").text
|
||||
issue_url = (
|
||||
f"{config.gitea_url}/{config.owner}/{config.repository}/issues/"
|
||||
f"{args.issue_number}"
|
||||
)
|
||||
content = build_archive(
|
||||
template, args.issue_number, args.title, page_name, issue_url
|
||||
)
|
||||
page = client.create_page(
|
||||
page_name,
|
||||
content,
|
||||
f"docs: 创建任务 #{args.issue_number} 归档草稿",
|
||||
)
|
||||
append_mapping(config, mapping)
|
||||
updated_config = load_config(config.path)
|
||||
messages = sync_all(updated_config, client)
|
||||
except WikiDocsError as exc:
|
||||
print(f"错误:{exc}")
|
||||
return 1
|
||||
|
||||
print(f"已创建 Wiki:{page.html_url}")
|
||||
for message in messages:
|
||||
print(message)
|
||||
print(f"已登记镜像:{local_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,33 @@
|
||||
"""从 Gitea Wiki 单向导出本地 docs 镜像。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
from wiki_docs import DEFAULT_CONFIG, WikiClient, WikiDocsError, load_config, sync_all
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="从 Gitea Wiki 单向同步 docs 镜像")
|
||||
parser.add_argument(
|
||||
"--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
config = load_config(Path(args.config).resolve())
|
||||
messages = sync_all(config, WikiClient(config), check=args.check)
|
||||
except WikiDocsError as exc:
|
||||
print(f"错误:{exc}")
|
||||
return 1
|
||||
for message in messages:
|
||||
print(message)
|
||||
print("Wiki 镜像检查通过" if args.check else "Wiki 镜像同步完成")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,394 @@
|
||||
"""Gitea Wiki 到本地 docs 镜像的共享实现。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_CONFIG = ROOT / "wiki-docs.json"
|
||||
MIRROR_START = "<!-- gitea-wiki-mirror:start -->"
|
||||
MIRROR_END = "<!-- gitea-wiki-mirror:end -->"
|
||||
HEADER_PATTERN = re.compile(
|
||||
rf"\A{re.escape(MIRROR_START)}\n(?P<metadata>.*?)\n"
|
||||
rf"{re.escape(MIRROR_END)}\n\n(?P<body>.*)\Z",
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
|
||||
class WikiDocsError(RuntimeError):
|
||||
"""可供命令行直接展示的 Wiki 文档错误。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Mapping:
|
||||
page: str
|
||||
path: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Config:
|
||||
path: Path
|
||||
gitea_url: str
|
||||
owner: str
|
||||
repository: str
|
||||
mappings: tuple[Mapping, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WikiPage:
|
||||
title: str
|
||||
sub_url: str
|
||||
text: str
|
||||
revision: str
|
||||
html_url: str
|
||||
|
||||
|
||||
def _required_string(data: dict[str, Any], key: str) -> str:
|
||||
value = data.get(key)
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise WikiDocsError(f"配置字段 {key!r} 必须是非空字符串")
|
||||
return value.strip()
|
||||
|
||||
|
||||
def validate_mappings(raw_mappings: Any) -> tuple[Mapping, ...]:
|
||||
"""校验显式页面映射,确保只会写入 docs 下的 Markdown。"""
|
||||
|
||||
if not isinstance(raw_mappings, list) or not raw_mappings:
|
||||
raise WikiDocsError("配置字段 'mappings' 必须是非空数组")
|
||||
|
||||
mappings: list[Mapping] = []
|
||||
pages: set[str] = set()
|
||||
paths: set[str] = set()
|
||||
for index, item in enumerate(raw_mappings, start=1):
|
||||
if not isinstance(item, dict):
|
||||
raise WikiDocsError(f"第 {index} 个映射必须是对象")
|
||||
page = _required_string(item, "page")
|
||||
path = _required_string(item, "path").replace("\\", "/")
|
||||
pure_path = PurePosixPath(path)
|
||||
if (
|
||||
pure_path.is_absolute()
|
||||
or ".." in pure_path.parts
|
||||
or not pure_path.parts
|
||||
or pure_path.parts[0] != "docs"
|
||||
or pure_path.suffix.lower() != ".md"
|
||||
):
|
||||
raise WikiDocsError(f"镜像路径必须是 docs/ 下的 Markdown:{path}")
|
||||
if page in pages:
|
||||
raise WikiDocsError(f"Wiki 页面重复映射:{page}")
|
||||
if path in paths:
|
||||
raise WikiDocsError(f"本地路径重复映射:{path}")
|
||||
pages.add(page)
|
||||
paths.add(path)
|
||||
mappings.append(Mapping(page=page, path=path))
|
||||
return tuple(mappings)
|
||||
|
||||
|
||||
def load_config(path: Path = DEFAULT_CONFIG) -> Config:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise WikiDocsError(f"无法读取 Wiki 映射配置 {path}: {exc}") from exc
|
||||
if not isinstance(raw, dict) or raw.get("schema_version") != 1:
|
||||
raise WikiDocsError("wiki-docs.json 的 schema_version 必须为 1")
|
||||
configured_url = _required_string(raw, "gitea_url")
|
||||
gitea_url = os.environ.get("GITEA_URL", configured_url).rstrip("/")
|
||||
if gitea_url.endswith("/api/v1"):
|
||||
gitea_url = gitea_url[: -len("/api/v1")]
|
||||
return Config(
|
||||
path=path,
|
||||
gitea_url=gitea_url,
|
||||
owner=_required_string(raw, "owner"),
|
||||
repository=_required_string(raw, "repository"),
|
||||
mappings=validate_mappings(raw.get("mappings")),
|
||||
)
|
||||
|
||||
|
||||
class WikiClient:
|
||||
"""只使用标准库访问 Gitea Wiki API。"""
|
||||
|
||||
def __init__(self, config: Config, token: str | None = None) -> None:
|
||||
self.config = config
|
||||
self.token = token if token is not None else os.environ.get("GITEA_TOKEN")
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
api_path: str,
|
||||
*,
|
||||
payload: dict[str, Any] | None = None,
|
||||
query: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
url = f"{self.config.gitea_url}/api/v1{api_path}"
|
||||
if query:
|
||||
url = f"{url}?{urlencode(query)}"
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
data = None
|
||||
if payload is not None:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(url, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=30) as response:
|
||||
body = response.read()
|
||||
except HTTPError as exc:
|
||||
if self.token and method == "GET" and exc.code in {401, 403, 404}:
|
||||
# 公共仓库可能可匿名读取,而当前 shell 中的通用令牌属于
|
||||
# 另一个实例或已失效。只对只读请求安全降级为匿名访问。
|
||||
anonymous_headers = {"Accept": "application/json"}
|
||||
anonymous_request = Request(
|
||||
url, data=data, headers=anonymous_headers, method=method
|
||||
)
|
||||
try:
|
||||
with urlopen(anonymous_request, timeout=30) as response:
|
||||
body = response.read()
|
||||
except HTTPError as anonymous_exc:
|
||||
detail = anonymous_exc.read().decode("utf-8", errors="replace")
|
||||
raise WikiDocsError(
|
||||
f"Gitea API {method} {api_path} 返回 "
|
||||
f"{anonymous_exc.code}: {detail}"
|
||||
) from anonymous_exc
|
||||
except URLError as anonymous_exc:
|
||||
raise WikiDocsError(
|
||||
f"无法连接 Gitea:{anonymous_exc.reason}"
|
||||
) from anonymous_exc
|
||||
else:
|
||||
detail = exc.read().decode("utf-8", errors="replace")
|
||||
raise WikiDocsError(
|
||||
f"Gitea API {method} {api_path} 返回 {exc.code}: {detail}"
|
||||
) from exc
|
||||
except URLError as exc:
|
||||
raise WikiDocsError(f"无法连接 Gitea:{exc.reason}") from exc
|
||||
if not body:
|
||||
return None
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise WikiDocsError("Gitea API 返回了无效的 UTF-8 JSON") from exc
|
||||
|
||||
def list_pages(self) -> list[dict[str, Any]]:
|
||||
pages: list[dict[str, Any]] = []
|
||||
page_number = 1
|
||||
while True:
|
||||
batch = self._request(
|
||||
"GET",
|
||||
f"/repos/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/pages",
|
||||
query={"page": page_number, "limit": 50},
|
||||
)
|
||||
if not isinstance(batch, list):
|
||||
raise WikiDocsError("Gitea Wiki 页面列表格式无效")
|
||||
pages.extend(item for item in batch if isinstance(item, dict))
|
||||
if len(batch) < 50:
|
||||
return pages
|
||||
page_number += 1
|
||||
|
||||
def get_page(self, page_name: str) -> WikiPage:
|
||||
metadata = next(
|
||||
(
|
||||
item
|
||||
for item in self.list_pages()
|
||||
if item.get("title") == page_name or item.get("sub_url") == page_name
|
||||
),
|
||||
None,
|
||||
)
|
||||
if metadata is None:
|
||||
raise WikiDocsError(
|
||||
f"Wiki 页面不存在:{page_name};不会自动删除或重命名本地镜像"
|
||||
)
|
||||
sub_url = _required_string(metadata, "sub_url")
|
||||
page = self._request(
|
||||
"GET",
|
||||
f"/repos/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/page/"
|
||||
f"{quote(sub_url, safe='%')}",
|
||||
)
|
||||
if not isinstance(page, dict):
|
||||
raise WikiDocsError(f"Wiki 页面响应格式无效:{page_name}")
|
||||
encoded_content = page.get("content_base64")
|
||||
if not isinstance(encoded_content, str):
|
||||
raise WikiDocsError(f"Wiki 页面没有 content_base64:{page_name}")
|
||||
try:
|
||||
text = base64.b64decode(encoded_content, validate=True).decode("utf-8")
|
||||
except (ValueError, UnicodeDecodeError) as exc:
|
||||
raise WikiDocsError(f"Wiki 页面不是有效的 UTF-8 Markdown:{page_name}") from exc
|
||||
last_commit = page.get("last_commit")
|
||||
revision = last_commit.get("sha") if isinstance(last_commit, dict) else None
|
||||
if not isinstance(revision, str) or not revision:
|
||||
raise WikiDocsError(f"Wiki 页面缺少 revision:{page_name}")
|
||||
title = page.get("title")
|
||||
resolved_title = title if isinstance(title, str) and title else page_name
|
||||
html_url = (
|
||||
f"{self.config.gitea_url}/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/{quote(sub_url, safe='%')}"
|
||||
)
|
||||
return WikiPage(
|
||||
title=resolved_title,
|
||||
sub_url=sub_url,
|
||||
text=normalize_body(text),
|
||||
revision=revision,
|
||||
html_url=html_url,
|
||||
)
|
||||
|
||||
def create_page(self, title: str, content: str, message: str) -> WikiPage:
|
||||
if not self.token:
|
||||
raise WikiDocsError("创建 Wiki 页面需要通过 GITEA_TOKEN 提供写入令牌")
|
||||
encoded = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
self._request(
|
||||
"POST",
|
||||
f"/repos/{quote(self.config.owner, safe='')}/"
|
||||
f"{quote(self.config.repository, safe='')}/wiki/new",
|
||||
payload={"title": title, "content_base64": encoded, "message": message},
|
||||
)
|
||||
return self.get_page(title)
|
||||
|
||||
|
||||
def normalize_body(text: str) -> str:
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").rstrip() + "\n"
|
||||
|
||||
|
||||
def parse_mirror(text: str) -> tuple[dict[str, str], str]:
|
||||
match = HEADER_PATTERN.match(text.replace("\r\n", "\n").replace("\r", "\n"))
|
||||
if match is None:
|
||||
raise WikiDocsError("缺少或损坏 gitea-wiki-mirror 元数据头")
|
||||
metadata: dict[str, str] = {}
|
||||
for line in match.group("metadata").splitlines():
|
||||
key, separator, value = line.partition(": ")
|
||||
if not separator or not key or not value:
|
||||
raise WikiDocsError(f"无效的镜像元数据行:{line}")
|
||||
metadata[key] = value
|
||||
return metadata, normalize_body(match.group("body"))
|
||||
|
||||
|
||||
def render_mirror(page: WikiPage, existing: str | None = None) -> str:
|
||||
synchronized_at: str | None = None
|
||||
if existing is not None:
|
||||
try:
|
||||
metadata, body = parse_mirror(existing)
|
||||
except WikiDocsError:
|
||||
pass
|
||||
else:
|
||||
if metadata.get("wiki_revision") == page.revision and body == page.text:
|
||||
synchronized_at = metadata.get("synchronized_at")
|
||||
if not synchronized_at:
|
||||
synchronized_at = datetime.now(timezone.utc).isoformat(timespec="seconds").replace(
|
||||
"+00:00", "Z"
|
||||
)
|
||||
header = "\n".join(
|
||||
(
|
||||
MIRROR_START,
|
||||
"generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)",
|
||||
f"wiki_page: {page.title}",
|
||||
f"wiki_url: {page.html_url}",
|
||||
f"wiki_revision: {page.revision}",
|
||||
f"synchronized_at: {synchronized_at}",
|
||||
MIRROR_END,
|
||||
)
|
||||
)
|
||||
return f"{header}\n\n{page.text}"
|
||||
|
||||
|
||||
def dirty_mirror_paths(config: Config, root: Path = ROOT) -> list[str]:
|
||||
paths = [mapping.path for mapping in config.mappings]
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain", "--", *paths],
|
||||
cwd=root,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
return [line for line in result.stdout.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def _write_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
handle, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent
|
||||
)
|
||||
try:
|
||||
with os.fdopen(handle, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(content)
|
||||
os.replace(temporary_name, path)
|
||||
except BaseException:
|
||||
Path(temporary_name).unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
|
||||
def check_mirror(mapping: Mapping, page: WikiPage, path: Path) -> list[str]:
|
||||
if not path.is_file():
|
||||
return [f"缺少镜像:{mapping.path}"]
|
||||
try:
|
||||
metadata, body = parse_mirror(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, WikiDocsError) as exc:
|
||||
return [f"镜像无效 {mapping.path}: {exc}"]
|
||||
expected = {
|
||||
"wiki_page": page.title,
|
||||
"wiki_url": page.html_url,
|
||||
"wiki_revision": page.revision,
|
||||
}
|
||||
errors = [
|
||||
f"{mapping.path} 的 {key} 不一致"
|
||||
for key, value in expected.items()
|
||||
if metadata.get(key) != value
|
||||
]
|
||||
if not metadata.get("synchronized_at"):
|
||||
errors.append(f"{mapping.path} 缺少 synchronized_at")
|
||||
if body != page.text:
|
||||
errors.append(f"{mapping.path} 的正文与 Wiki 不一致")
|
||||
return errors
|
||||
|
||||
|
||||
def sync_all(config: Config, client: WikiClient, *, check: bool = False) -> list[str]:
|
||||
"""检查或写入所有显式映射;绝不处理映射外的文件。"""
|
||||
|
||||
if not check:
|
||||
dirty = dirty_mirror_paths(config)
|
||||
if dirty:
|
||||
details = "\n".join(dirty)
|
||||
raise WikiDocsError(
|
||||
"已映射的本地镜像存在未提交改动,已停止以防覆盖:\n" + details
|
||||
)
|
||||
|
||||
messages: list[str] = []
|
||||
for mapping in config.mappings:
|
||||
page = client.get_page(mapping.page)
|
||||
target = ROOT / PurePosixPath(mapping.path)
|
||||
if check:
|
||||
errors = check_mirror(mapping, page, target)
|
||||
if errors:
|
||||
raise WikiDocsError("\n".join(errors))
|
||||
messages.append(f"一致:{mapping.path} <- {page.title}@{page.revision[:12]}")
|
||||
continue
|
||||
existing = target.read_text(encoding="utf-8") if target.is_file() else None
|
||||
rendered = render_mirror(page, existing)
|
||||
if existing != rendered:
|
||||
_write_atomic(target, rendered)
|
||||
messages.append(f"已更新:{mapping.path} <- {page.title}@{page.revision[:12]}")
|
||||
else:
|
||||
messages.append(f"无变化:{mapping.path} <- {page.title}@{page.revision[:12]}")
|
||||
return messages
|
||||
|
||||
|
||||
def append_mapping(config: Config, mapping: Mapping) -> None:
|
||||
raw = json.loads(config.path.read_text(encoding="utf-8"))
|
||||
mappings = validate_mappings(raw.get("mappings"))
|
||||
if any(item.page == mapping.page or item.path == mapping.path for item in mappings):
|
||||
raise WikiDocsError(f"页面或路径已经登记:{mapping.page} -> {mapping.path}")
|
||||
raw["mappings"].append({"page": mapping.page, "path": mapping.path})
|
||||
rendered = json.dumps(raw, ensure_ascii=False, indent=2) + "\n"
|
||||
_write_atomic(config.path, rendered)
|
||||
@@ -0,0 +1,184 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "dev_scripts"))
|
||||
|
||||
from check_harness import ( # noqa: E402
|
||||
CORE_DOCUMENT_REQUIREMENTS,
|
||||
CORE_PAGE_PATHS,
|
||||
REQUIRED_FILES,
|
||||
check_claude_code_entry,
|
||||
check_core_documents,
|
||||
check_agent_efficiency_rules,
|
||||
check_task_template,
|
||||
core_mapping_errors,
|
||||
missing_sections,
|
||||
)
|
||||
from wiki_docs import load_config # noqa: E402
|
||||
|
||||
|
||||
class CoreDocumentTests(unittest.TestCase):
|
||||
def test_current_core_documents_have_required_sections(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_core_documents(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_missing_sections_reports_each_heading(self) -> None:
|
||||
missing = missing_sections("# 页面\n## 已有\n", ("## 已有", "## 缺少"))
|
||||
self.assertEqual(missing, ["## 缺少"])
|
||||
|
||||
def test_every_core_document_is_required(self) -> None:
|
||||
for path in CORE_DOCUMENT_REQUIREMENTS:
|
||||
self.assertIn(path, REQUIRED_FILES)
|
||||
|
||||
def test_every_core_page_has_exact_mapping(self) -> None:
|
||||
config = load_config()
|
||||
mappings = {mapping.page: mapping.path for mapping in config.mappings}
|
||||
for page, path in CORE_PAGE_PATHS.items():
|
||||
self.assertEqual(mappings.get(page), path)
|
||||
|
||||
def test_existing_project_adoption_guide_is_core_document(self) -> None:
|
||||
path = "docs/08-existing-project-adoption.md"
|
||||
self.assertEqual(
|
||||
CORE_PAGE_PATHS.get("Existing-Project-Adoption-Guide"),
|
||||
path,
|
||||
)
|
||||
self.assertIn(path, CORE_DOCUMENT_REQUIREMENTS)
|
||||
self.assertIn("## 可复制 Agent 指令", CORE_DOCUMENT_REQUIREMENTS[path])
|
||||
|
||||
def test_missing_or_wrong_core_mapping_is_reported(self) -> None:
|
||||
errors = core_mapping_errors({"Home": "docs/wrong.md"})
|
||||
self.assertTrue(any("Home -> docs/README.md" in error for error in errors))
|
||||
self.assertTrue(
|
||||
any("Architecture-and-Code-Map" in error for error in errors)
|
||||
)
|
||||
|
||||
|
||||
class TaskTemplateTests(unittest.TestCase):
|
||||
def test_task_template_requires_document_impact(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_task_template(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_task_template_requires_delivery_document_impact(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text(
|
||||
"## 依赖与并行\n"
|
||||
"- 前置工单:无 / #编号\n"
|
||||
"- 是否允许与前置工单并行:是 / 否\n"
|
||||
"- 原因:\n"
|
||||
"## 原始需求\n"
|
||||
"- 来源:用户对话 / Gitea / 其他\n"
|
||||
"- 提出时间:\n"
|
||||
"- 关键原话或脱敏摘要:\n"
|
||||
"## 需求变化记录\n"
|
||||
"| 日期 | 变化内容 | 原因 | 用户确认 |\n"
|
||||
"## 文档影响\n"
|
||||
"- [ ] 不影响长期文档,原因:\n"
|
||||
"- [ ] 更新架构与代码地图\n"
|
||||
"- [ ] 更新业务规则与术语\n"
|
||||
"- [ ] 更新常见修改或故障排查\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 交付文档影响", errors)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- [ ] 无交付文档影响,原因:",
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_task_template_requires_subproject_impact(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text("## 基本信息\n", encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 子项目影响", errors)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 是否跨子项目:是 / 否",
|
||||
errors,
|
||||
)
|
||||
self.assertIn(
|
||||
"单元任务模板缺少:- 是否修改共享接口或契约:是 / 否;唯一事实来源:",
|
||||
errors,
|
||||
)
|
||||
|
||||
def test_task_template_requires_dependency_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text(
|
||||
"## 文档影响\n"
|
||||
"- [ ] 不影响长期文档,原因:\n"
|
||||
"- [ ] 更新架构与代码地图\n"
|
||||
"- [ ] 更新业务规则与术语\n"
|
||||
"- [ ] 更新常见修改或故障排查\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 依赖与并行", errors)
|
||||
self.assertIn("单元任务模板缺少:- 前置工单:无 / #编号", errors)
|
||||
|
||||
def test_task_template_requires_requirement_traceability(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
template = root / ".gitea" / "issue_template" / "task.md"
|
||||
template.parent.mkdir(parents=True)
|
||||
template.write_text(
|
||||
"## 依赖与并行\n"
|
||||
"- 前置工单:无 / #编号\n"
|
||||
"- 是否允许与前置工单并行:是 / 否\n"
|
||||
"- 原因:\n"
|
||||
"## 文档影响\n"
|
||||
"- [ ] 不影响长期文档,原因:\n"
|
||||
"- [ ] 更新架构与代码地图\n"
|
||||
"- [ ] 更新业务规则与术语\n"
|
||||
"- [ ] 更新常见修改或故障排查\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_task_template(errors, root)
|
||||
self.assertIn("单元任务模板缺少:## 原始需求", errors)
|
||||
self.assertIn("单元任务模板缺少:## 需求变化记录", errors)
|
||||
|
||||
|
||||
class AgentRuleTests(unittest.TestCase):
|
||||
def test_required_agent_rules_are_present(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_agent_efficiency_rules(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_claude_code_entry_imports_shared_rules(self) -> None:
|
||||
errors: list[str] = []
|
||||
check_claude_code_entry(errors)
|
||||
self.assertEqual(errors, [])
|
||||
|
||||
def test_claude_code_entry_requires_exact_import_line(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "CLAUDE.md").write_text(
|
||||
"共同规则事实来源\n只记录 Claude Code 特有\n"
|
||||
"共同规则只修改 `AGENTS.md`\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
errors: list[str] = []
|
||||
check_claude_code_entry(errors, root)
|
||||
self.assertIn("CLAUDE.md 缺少独立的 @AGENTS.md 导入", errors)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,163 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "dev_scripts"))
|
||||
|
||||
from new_task_archive import build_archive, safe_title # noqa: E402
|
||||
from wiki_docs import ( # noqa: E402
|
||||
Config,
|
||||
Mapping,
|
||||
WikiClient,
|
||||
WikiDocsError,
|
||||
WikiPage,
|
||||
dirty_mirror_paths,
|
||||
load_config,
|
||||
parse_mirror,
|
||||
render_mirror,
|
||||
sync_all,
|
||||
validate_mappings,
|
||||
)
|
||||
|
||||
|
||||
class MappingTests(unittest.TestCase):
|
||||
def test_rejects_path_outside_docs(self) -> None:
|
||||
with self.assertRaisesRegex(WikiDocsError, "docs/"):
|
||||
validate_mappings([{"page": "Home", "path": "README.md"}])
|
||||
|
||||
def test_rejects_duplicate_page(self) -> None:
|
||||
with self.assertRaisesRegex(WikiDocsError, "重复映射"):
|
||||
validate_mappings(
|
||||
[
|
||||
{"page": "Home", "path": "docs/README.md"},
|
||||
{"page": "Home", "path": "docs/other.md"},
|
||||
]
|
||||
)
|
||||
|
||||
def test_normalizes_api_suffix_from_environment(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
path = Path(directory) / "wiki-docs.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": 1,
|
||||
"gitea_url": "http://configured.example",
|
||||
"owner": "owner",
|
||||
"repository": "repo",
|
||||
"mappings": [
|
||||
{"page": "Home", "path": "docs/README.md"}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch.dict(
|
||||
os.environ, {"GITEA_URL": "http://gitea.example/api/v1"}, clear=False
|
||||
):
|
||||
config = load_config(path)
|
||||
self.assertEqual(config.gitea_url, "http://gitea.example")
|
||||
|
||||
|
||||
class MirrorTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.page = WikiPage(
|
||||
title="Home",
|
||||
sub_url="Home",
|
||||
text="# 首页\n",
|
||||
revision="a" * 40,
|
||||
html_url="http://gitea.example/o/r/wiki/Home",
|
||||
)
|
||||
|
||||
def test_render_includes_traceable_metadata(self) -> None:
|
||||
rendered = render_mirror(self.page)
|
||||
metadata, body = parse_mirror(rendered)
|
||||
self.assertEqual(metadata["wiki_page"], "Home")
|
||||
self.assertEqual(metadata["wiki_revision"], "a" * 40)
|
||||
self.assertTrue(metadata["synchronized_at"].endswith("Z"))
|
||||
self.assertEqual(body, "# 首页\n")
|
||||
|
||||
def test_unchanged_revision_preserves_sync_time(self) -> None:
|
||||
first = render_mirror(self.page)
|
||||
second = render_mirror(self.page, first)
|
||||
self.assertEqual(first, second)
|
||||
|
||||
@patch("wiki_docs.subprocess.run")
|
||||
def test_dirty_mirror_paths_are_reported(self, run) -> None:
|
||||
run.return_value.stdout = " M docs/README.md\n"
|
||||
config = Config(
|
||||
path=Path("wiki-docs.json"),
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
self.assertEqual(dirty_mirror_paths(config), [" M docs/README.md"])
|
||||
|
||||
@patch("wiki_docs.dirty_mirror_paths", return_value=[" M docs/README.md"])
|
||||
def test_sync_stops_before_reading_wiki_when_mirror_is_dirty(self, _dirty) -> None:
|
||||
config = Config(
|
||||
path=Path("wiki-docs.json"),
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("Home", "docs/README.md"),),
|
||||
)
|
||||
client = Mock()
|
||||
with self.assertRaisesRegex(WikiDocsError, "未提交改动"):
|
||||
sync_all(config, client)
|
||||
client.get_page.assert_not_called()
|
||||
|
||||
|
||||
class WikiClientTests(unittest.TestCase):
|
||||
def test_encoded_unicode_sub_url_is_not_double_encoded(self) -> None:
|
||||
config = Config(
|
||||
path=Path("wiki-docs.json"),
|
||||
gitea_url="http://gitea.example",
|
||||
owner="o",
|
||||
repository="r",
|
||||
mappings=(Mapping("中文", "docs/chinese.md"),),
|
||||
)
|
||||
client = WikiClient(config, token="")
|
||||
client.list_pages = Mock(
|
||||
return_value=[{"title": "中文", "sub_url": "%E4%B8%AD%E6%96%87.-"}]
|
||||
)
|
||||
encoded = __import__("base64").b64encode("# 中文\n".encode()).decode()
|
||||
with patch.object(
|
||||
client,
|
||||
"_request",
|
||||
return_value={
|
||||
"title": "中文",
|
||||
"content_base64": encoded,
|
||||
"last_commit": {"sha": "b" * 40},
|
||||
},
|
||||
) as request:
|
||||
page = client.get_page("中文")
|
||||
api_path = request.call_args.args[1]
|
||||
self.assertIn("%E4%B8%AD%E6%96%87.-", api_path)
|
||||
self.assertNotIn("%25E4", api_path)
|
||||
self.assertTrue(page.html_url.endswith("/%E4%B8%AD%E6%96%87.-"))
|
||||
|
||||
|
||||
class ArchiveTests(unittest.TestCase):
|
||||
def test_safe_title_handles_windows_characters(self) -> None:
|
||||
self.assertEqual(safe_title(' 修复:"登录" / 超时 '), "修复-登录-超时")
|
||||
|
||||
def test_build_archive_replaces_known_fields(self) -> None:
|
||||
template = "# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n"
|
||||
result = build_archive(template, "12", "修复登录", "Task-12-login", "http://i/12")
|
||||
self.assertIn("# 12 修复登录", result)
|
||||
self.assertIn("http://i/12", result)
|
||||
self.assertIn("Task-12-login", result)
|
||||
self.assertNotIn("YYYY-MM-DD", result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"gitea_url": "https://git.ilapage.cn",
|
||||
"owner": "ila",
|
||||
"repository": "yovision",
|
||||
"mappings": [
|
||||
{ "page": "Home", "path": "docs/README.md" },
|
||||
{ "page": "Project-Profile", "path": "docs/00-project-profile.md" },
|
||||
{ "page": "Development-Workflow", "path": "docs/01-workflow.md" },
|
||||
{ "page": "Architecture-and-Code-Map", "path": "docs/02-architecture-and-code-map.md" },
|
||||
{ "page": "Business-Rules-and-Glossary", "path": "docs/03-business-rules-and-glossary.md" },
|
||||
{ "page": "Local-Development-and-Verification", "path": "docs/04-local-development-and-verification.md" },
|
||||
{ "page": "Common-Changes", "path": "docs/05-common-changes.md" },
|
||||
{ "page": "Troubleshooting", "path": "docs/06-troubleshooting.md" },
|
||||
{ "page": "New-Project-Documentation-Setup", "path": "docs/07-new-project-documentation-setup.md" },
|
||||
{ "page": "Existing-Project-Adoption-Guide", "path": "docs/08-existing-project-adoption.md" },
|
||||
{ "page": "Product-Requirements", "path": "docs/09-product-requirements.md" },
|
||||
{ "page": "Requirements-Migration-Matrix", "path": "docs/10-requirements-migration-matrix.md" },
|
||||
{ "page": "Multi-Agent-Collaboration", "path": "docs/11-multi-agent-collaboration.md" },
|
||||
{ "page": "Product-Roadmap", "path": "docs/12-product-roadmap.md" },
|
||||
{ "page": "Delivery-Documentation-Guide", "path": "docs/delivery/README.md" },
|
||||
{ "page": "Audience-Document-Template", "path": "docs/delivery/audience-document-template.md" },
|
||||
{ "page": "Task-Archive-Template", "path": "docs/templates/task-archive.md" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user