diff --git a/dev_scripts/harness.py b/dev_scripts/harness.py new file mode 100644 index 0000000..52f84d8 --- /dev/null +++ b/dev_scripts/harness.py @@ -0,0 +1,722 @@ +"""DevHarness 单一命令行入口。 + +子命令: + check 检查必需文件、核心文档和已有任务快照结构 + sync 从 Gitea Wiki 单向导出或校验核心 docs 镜像 + archive 显式在 Gitea Wiki 创建可选任务快照 + export 人工按需把已有 Wiki 任务快照导出到 docs/task + +各子命令的实现逻辑取自原来的 check_harness.py、sync_wiki_docs.py、 +new_task_archive.py 和 export_task_archives.py,行为未改变。 +""" + +from __future__ import annotations + +import argparse +import re +from datetime import date +from pathlib import Path +from typing import Any + +from wiki_docs import ( + DEFAULT_CONFIG, + MIRROR_START, + WikiClient, + WikiDocsError, + dirty_paths, + load_config, + parse_mirror, + sync_all, + write_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", + "Product-Requirements-Overview": ( + "docs/09-product-requirements-overview.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": ( + "## 基本信息", + "## DevHarness 来源与基线", + "## 子项目与交付单元", + "## 技术栈与运行环境", + "## 阅读入口", + "## 常用命令", + "## 环境、配置与凭据", + ), + "docs/01-workflow.md": ( + "## Gitea 交互与工单最小读取", + "## 新项目 Wiki 初始化门禁", + "## 工单与设计证据双门禁", + "### 先判断是否需要工单", + "### 再判断设计证据", + "### 线上原型审核与按需导出", + "### 记录和重新确认", + "## 面向初级维护者的修改边界", + "## 每个任务的文档影响", + "## 需求记录与流转", + "## 稳定文档与可选历史快照", + "## 自然语言快捷指令", + "## 效率与范围控制", + "### 严格控制范围", + "### 渐进执行和修复", + "### 复用已验证事实", + "### 明确停止条件", + ), + "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/09-product-requirements-overview.md": ( + "## 本页用途", + "## 事实来源边界", + "## 当前需求索引", + "## 登记规则", + "## 原型与设计资产", + "### 原型门禁", + "### 线上原型与按需 HTML 快照", + "### 原型确认记录", + "## 状态规则", + "## 更新时机", + "## 最小验收清单", + ), + "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/harness.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: + """只校验新工作流导出的任务镜像,冻结的历史归档保持原样。 + + cmautobuy 在接入 Wiki 前已经积累了大量 `docs/task` 文件。它们没有 + Wiki 镜像头,也不应为了迁移而被批量重写。只有明确带镜像头的新文件 + 才执行当前 DevHarness 的严格校验。 + """ + + task_dir = ROOT / "docs" / "task" + for path in task_dir.glob("*.md"): + content = path.read_text(encoding="utf-8") + if not content.startswith(MIRROR_START): + continue + if not re.match(r"^\d+-.+\.md$", path.name): + errors.append(f"归档文件名不符合 <编号>-<标题>.md:{path.name}") + try: + metadata, _ = parse_mirror(content) + except WikiDocsError as exc: + errors.append(f"{path.name} 的任务镜像无效:{exc}") + continue + if re.fullmatch(r"Task-\d+-.+", metadata.get("wiki_page", "")) is None: + errors.append(f"{path.name} 的 wiki_page 不是任务归档页面") + if re.fullmatch(r"[0-9a-f]{40,64}", metadata.get("wiki_revision", "")) is None: + errors.append(f"{path.name} 的 wiki_revision 无效") + 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 / 其他", + "- 提出时间:", + "- 关键原话或脱敏摘要:", + "## 需求变化记录", + "| 日期 | 变化内容 | 原因 | 用户确认 |", + "## 设计与原型门禁", + "- 修改类型:纯显示文案 / 小范围 UI / 新组件 / 新页面或独立用户功能 / 重大交互或导航 / 非 UI / 恢复既有行为的 Bug", + "- 所需设计证据:无 / 标注截图 / 低保真图 / 已确认原型 / 架构、API、数据、状态或流程设计 / 原设计或复现证据", + "- 可编辑设计源、线上原型链接和访问检查:", + "- 审核版本、revision、复制版本或确认日期及识别方式:", + "- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求", + "- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):", + "- 状态:无 / 草稿 / 已确认 / 已废弃", + "- 确认人、确认时间和覆盖范围:", + "- 无需 UI 原型或无需任何原型的原因:", + "## 文档影响", + "- [ ] 不影响长期文档,原因:", + "- [ ] 更新架构与代码地图", + "- [ ] 更新业务规则与术语", + "- [ ] 更新常见修改或故障排查", + "## 交付文档影响", + "- [ ] 无交付文档影响,原因:", + "- [ ] 更新已有交付文档,受众与页面:", + "- [ ] 新增交付文档,受众与页面:", + "- [ ] 需要目标岗位或客户代表验证:是 / 否;验证方式:", + "## 任务记录与可选快照", + "- 单次任务事实来源:当前 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", + "Gitea 工单是单次任务需求、变化、实现、测试、提交和验收的事实来源", + "默认不创建任务归档", + "### Gitea 交互与工单最小读取", + "查询、创建、更新、评论、状态变更及关闭操作", + "优先关注当前状态、最新评论和首个未完成步骤", + "连接器不支持评论分页或增量读取时允许读取完整工单", + "不得为规避完整读取而新增本地工单、缓存或第二事实来源", + "### 新项目 Wiki 初始化门禁", + "`Home` 不存在时必须先创建 `Home`", + "不得把模板自带的本地 `docs/` 当作新项目 Wiki 已初始化的证据", + "提交只包含当前工单相关文件", + "### 工单与设计证据双门禁", + "新页面、独立用户功能、重大交互或导航变化", + "`prototypes/<工单号>/<版本>/index.html`", + "默认直接通过 Quant-UX 或其他设计工具的线上链接审核", + "已确认的本地快照不得原位覆盖", + "`导出原型 #N`", + "`导出全部原型`", + "代码组件名、类名、变量、国际化键、API 字段和数据库字段不是显示文案", + "### 自然语言快捷指令", + "`只分析`", + "`建工单`", + "`执行工单 #N`", + "`建工单并做`", + "`继续工单 #N`", + "`检查工单 #N`", + "`同步文档`", + "`导出原型 #N`", + "`导出全部原型`", + "`导出任务归档`", + "`导出全部任务归档`", + "`#N 验收通过`", + "### 需求记录与流转", + "不得臆造用户原话", + "不复制完整聊天", + "Gitea 工单全文不导出到仓库", + ) + for section in missing_sections(content, required): + errors.append(f"AGENTS.md 缺少:{section}") + + +def check_repository_readme(errors: list[str], root: Path = ROOT) -> None: + """检查快速开始包含线上 Wiki 初始化顺序和产品编码门禁。""" + + path = root / "README.md" + if not path.is_file(): + return + content = path.read_text(encoding="utf-8") + required = ( + "创建 Gitea 远端仓库并推送当前引导提交,启用工单和 Wiki", + "优先使用已配置的 Gitea MCP", + "`Home` 不存在时先创建并回读 `Home`", + "本地 `docs/` 的存在不能证明线上 Wiki 已初始化", + "python dev_scripts/harness.py sync --verify", + "Gitea 工单是单次任务唯一事实来源", + "默认不创建任务归档", + ) + for section in missing_sections(content, required): + errors.append(f"README.md 缺少:{section}") + + remote_index = content.find("创建 Gitea 远端仓库") + wiki_index = content.find("`Home` 不存在时先创建") + if remote_index < 0 or wiki_index < 0 or remote_index > wiki_index: + errors.append("README.md 必须先创建 Gitea 远端,再创建 Wiki Home") + + +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: + """检查核心映射与镜像头;任务快照由 check_archives 单独检查。""" + + 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") + if path.parent != ROOT / "docs" / "task" + } + 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 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) + + +# ---------------------------------------------------------------- 归档导出 + +TASK_PAGE_PATTERN = re.compile(r"^Task-(?P\d+)-(?P.+)$") + + +def task_revision(metadata: dict[str, Any], page_name: str) -> str: + last_commit = metadata.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}") + return revision + + +def existing_task_mirrors(root: Path = ROOT) -> dict[str, Path]: + """按镜像头匹配已有文件,兼容历史自定义文件名。""" + + mirrors: dict[str, Path] = {} + task_dir = root / "docs" / "task" + if not task_dir.is_dir(): + return mirrors + for path in task_dir.glob("*.md"): + content = path.read_text(encoding="utf-8") + if not content.startswith(MIRROR_START): + continue + try: + metadata, _ = parse_mirror(content) + except (OSError, UnicodeDecodeError, WikiDocsError) as exc: + raise WikiDocsError(f"已有任务镜像无效 {path.name}:{exc}") from exc + page_name = metadata.get("wiki_page", "") + if not TASK_PAGE_PATTERN.fullmatch(page_name): + raise WikiDocsError(f"已有任务镜像页面名无效 {path.name}:{page_name}") + if page_name in mirrors: + raise WikiDocsError(f"任务页面存在重复本地镜像:{page_name}") + mirrors[page_name] = path + return mirrors + + +def task_target(page_name: str, root: Path = ROOT) -> Path: + match = TASK_PAGE_PATTERN.fullmatch(page_name) + if match is None: + raise WikiDocsError(f"不是任务归档页面:{page_name}") + title = safe_title(match.group("title")) + if not title: + raise WikiDocsError(f"任务归档标题无效:{page_name}") + return root / "docs" / "task" / f"{match.group('number')}-{title}.md" + + +def export_task_archives( + client: WikiClient, *, export_all: bool = False, root: Path = ROOT +) -> list[str]: + """增量或全量读取任务归档;绝不删除本地文件。""" + + dirty = dirty_paths(["docs/task"], root) + if dirty: + raise WikiDocsError( + "本地任务镜像存在未提交改动,已停止以防覆盖:\n" + "\n".join(dirty) + ) + + existing = existing_task_mirrors(root) + pages = [] + for metadata in client.list_pages(): + title = metadata.get("title") + if isinstance(title, str) and TASK_PAGE_PATTERN.fullmatch(title): + pages.append((int(title.split("-", 2)[1]), title, metadata)) + pages.sort(key=lambda item: (item[0], item[1])) + + messages: list[str] = [] + targets: set[Path] = set() + for _, page_name, metadata in pages: + target = existing.get(page_name, task_target(page_name, root)) + if target in targets: + raise WikiDocsError(f"多个任务页面映射到同一本地路径:{target.name}") + targets.add(target) + revision = task_revision(metadata, page_name) + if not export_all and target.is_file(): + local_metadata, _ = parse_mirror(target.read_text(encoding="utf-8")) + if ( + local_metadata.get("wiki_page") == page_name + and local_metadata.get("wiki_revision") == revision + ): + messages.append(f"跳过:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + continue + page = client.get_page_from_metadata(metadata, page_name) + changed = write_mirror(target, page) + action = "已导出" if changed else "无变化" + messages.append(f"{action}:{target.relative_to(root)} <- {page_name}@{revision[:12]}") + return messages + + +# ---------------------------------------------------------------- 子命令入口 + + +def run_check(args: argparse.Namespace) -> int: + 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_repository_readme(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 + + +def run_sync(args: argparse.Namespace) -> int: + """--verify 依次执行导出、结构检查和一致性校验,替代原来的三条命令。""" + + if args.verify: + steps = ( + ("同步", lambda: run_sync( + argparse.Namespace(check=False, verify=False, config=args.config))), + ("结构检查", lambda: run_check(argparse.Namespace(strict=True))), + ("一致性校验", lambda: run_sync( + argparse.Namespace(check=True, verify=False, config=args.config))), + ) + for name, step in steps: + code = step() + if code != 0: + print(f"错误:{name}未通过,已停止") + return code + return 0 + + 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 + + +def run_archive(args: argparse.Namespace) -> int: + 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}" + client = WikiClient(config) + if any(item.get("title") == page_name for item in client.list_pages()): + raise WikiDocsError(f"任务归档已经存在:{page_name}") + 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} 归档草稿", + ) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + + print(f"已创建 Wiki:{page.html_url}") + print("未导出本地任务归档;需要时运行 harness.py export") + return 0 + + +def run_export(args: argparse.Namespace) -> int: + try: + config = load_config(Path(args.config).resolve()) + messages = export_task_archives(WikiClient(config), export_all=args.all) + except WikiDocsError as exc: + print(f"错误:{exc}") + return 1 + for message in messages: + print(message) + print("任务归档全量导出完成" if args.all else "任务归档增量导出完成") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="DevHarness 检查、同步与归档工具") + sub = parser.add_subparsers(dest="command", required=True) + + p_check = sub.add_parser("check", help="检查 DevHarness 项目结构") + p_check.add_argument( + "--strict", action="store_true", help="项目档案有占位内容时返回失败" + ) + p_check.set_defaults(func=run_check) + + p_sync = sub.add_parser("sync", help="从 Gitea Wiki 单向同步核心 docs 镜像") + p_sync.add_argument( + "--check", action="store_true", help="只检查 Wiki 与镜像是否一致,不写文件" + ) + p_sync.add_argument( + "--verify", + action="store_true", + help="依次执行导出、check --strict 和一致性校验", + ) + p_sync.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 页面映射 JSON 文件" + ) + p_sync.set_defaults(func=run_sync) + + p_archive = sub.add_parser("archive", help="显式在 Gitea Wiki 创建可选任务快照") + p_archive.add_argument("issue_number", help="Gitea 工单号,例如 123") + p_archive.add_argument("title", help="简短任务标题") + p_archive.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="Wiki 映射配置" + ) + p_archive.set_defaults(func=run_archive) + + p_export = sub.add_parser("export", help="人工按需导出 Gitea Wiki 任务归档") + p_export.add_argument( + "--all", + action="store_true", + help="全量读取全部线上任务归档;默认按 revision 增量", + ) + p_export.add_argument( + "--config", default=str(DEFAULT_CONFIG), help="核心 Wiki 映射配置" + ) + p_export.set_defaults(func=run_export) + + args = parser.parse_args() + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dev_scripts/wiki_docs.py b/dev_scripts/wiki_docs.py new file mode 100644 index 0000000..85b6cae --- /dev/null +++ b/dev_scripts/wiki_docs.py @@ -0,0 +1,423 @@ +"""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};不会自动删除或重命名本地镜像" + ) + return self.get_page_from_metadata(metadata, page_name) + + def get_page_from_metadata( + self, metadata: dict[str, Any], page_name: str | None = None + ) -> WikiPage: + """使用页面列表元数据读取正文,避免重复获取完整页面列表。""" + + resolved_name = page_name or _required_string(metadata, "title") + 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 页面响应格式无效:{resolved_name}") + encoded_content = page.get("content_base64") + if not isinstance(encoded_content, str): + raise WikiDocsError(f"Wiki 页面没有 content_base64:{resolved_name}") + try: + text = base64.b64decode(encoded_content, validate=True).decode("utf-8") + except (ValueError, UnicodeDecodeError) as exc: + raise WikiDocsError( + f"Wiki 页面不是有效的 UTF-8 Markdown:{resolved_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:{resolved_name}") + title = page.get("title") + resolved_title = title if isinstance(title, str) and title else resolved_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]: + return dirty_paths([mapping.path for mapping in config.mappings], root) + + +def dirty_paths(paths: list[str], root: Path = ROOT) -> list[str]: + """返回指定路径中已有、修改或未跟踪的工作区条目。""" + + if not paths: + return [] + 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 write_mirror(path: Path, page: WikiPage) -> bool: + """写入一份 Wiki 镜像;内容无变化时返回 False。""" + + existing = path.read_text(encoding="utf-8") if path.is_file() else None + rendered = render_mirror(page, existing) + if existing == rendered: + return False + _write_atomic(path, rendered) + return True + + +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) + diff --git a/tests/test_harness_docs.py b/tests/test_harness_docs.py new file mode 100644 index 0000000..2b25d05 --- /dev/null +++ b/tests/test_harness_docs.py @@ -0,0 +1,279 @@ +from __future__ import annotations + +import sys +import tempfile +import unittest +import unittest.mock +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "dev_scripts")) + +import harness # noqa: E402 +from harness import ( # noqa: E402 + CORE_DOCUMENT_REQUIREMENTS, + CORE_PAGE_PATHS, + REQUIRED_FILES, + check_claude_code_entry, + check_required_files, + check_core_documents, + check_agent_efficiency_rules, + check_repository_readme, + 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_task_archives_are_not_core_mappings(self) -> None: + config = load_config() + self.assertFalse( + any(mapping.path.startswith("docs/task/") for mapping in config.mappings) + ) + + def test_product_requirements_overview_is_core_document(self) -> None: + path = "docs/09-product-requirements-overview.md" + self.assertEqual( + CORE_PAGE_PATHS.get("Product-Requirements-Overview"), + path, + ) + required = CORE_DOCUMENT_REQUIREMENTS[path] + self.assertIn("## 当前需求索引", required) + self.assertIn("## 原型与设计资产", required) + self.assertIn("### 原型门禁", required) + self.assertIn("### 线上原型与按需 HTML 快照", required) + self.assertIn("### 原型确认记录", required) + self.assertIn("## 更新时机", required) + + def test_workflow_requires_ticket_and_design_evidence_gates(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## 工单与设计证据双门禁", required) + self.assertIn("### 先判断是否需要工单", required) + self.assertIn("### 再判断设计证据", required) + self.assertIn("### 线上原型审核与按需导出", required) + self.assertIn("### 记录和重新确认", required) + + def test_workflow_requires_online_wiki_initialization_gate(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## 新项目 Wiki 初始化门禁", workflow) + + def test_workflow_requires_issue_only_task_record(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## 稳定文档与可选历史快照", workflow) + + def test_workflow_requires_gitea_mcp_and_minimal_issue_reading(self) -> None: + workflow = CORE_DOCUMENT_REQUIREMENTS["docs/01-workflow.md"] + self.assertIn("## Gitea 交互与工单最小读取", workflow) + + errors: list[str] = [] + check_agent_efficiency_rules(errors) + self.assertEqual(errors, []) + + def test_project_profile_requires_dev_harness_baseline(self) -> None: + required = CORE_DOCUMENT_REQUIREMENTS["docs/00-project-profile.md"] + self.assertIn("## DevHarness 来源与基线", required) + + 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_design_and_prototype_gate(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( + "单元任务模板缺少:- 本地 HTML 导出:未要求 / 用户明确要求 / 项目规则要求", + errors, + ) + self.assertIn( + "单元任务模板缺少:- 本地 HTML 路径、版本和资源检查(仅显式导出时填写):", + errors, + ) + + def test_task_template_requires_optional_snapshot_policy(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, + ) + + 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_repository_readme_requires_online_wiki_gate(self) -> None: + errors: list[str] = [] + check_repository_readme(errors) + self.assertEqual(errors, []) + + def test_repository_readme_rejects_missing_gate(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "README.md").write_text( + "# 项目\n创建 Gitea 远端仓库\n", + encoding="utf-8", + ) + errors: list[str] = [] + check_repository_readme(errors, root) + self.assertTrue(any("README.md 缺少" in error for error in 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() diff --git a/tests/test_wiki_docs.py b/tests/test_wiki_docs.py new file mode 100644 index 0000000..da97e57 --- /dev/null +++ b/tests/test_wiki_docs.py @@ -0,0 +1,332 @@ +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 harness import ( # noqa: E402 + build_archive, + existing_task_mirrors, + export_task_archives, + main as harness_main, + safe_title, + task_target, +) +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) + + @patch("harness.WikiClient") + def test_create_archive_does_not_change_core_mapping(self, client_class) -> None: + with tempfile.TemporaryDirectory() as directory: + config_path = Path(directory) / "wiki-docs.json" + original = json.dumps( + { + "schema_version": 1, + "gitea_url": "http://gitea.example", + "owner": "o", + "repository": "r", + "mappings": [ + {"page": "Home", "path": "docs/README.md"} + ], + } + ) + config_path.write_text(original, encoding="utf-8") + client = client_class.return_value + client.list_pages.return_value = [] + client.get_page.return_value = WikiPage( + title="Task-Archive-Template", + sub_url="Task-Archive-Template.-", + text="# <工单号> <标题>\nYYYY-MM-DD\n<链接>\n<页面名>\n", + revision="a" * 40, + html_url="http://gitea.example/wiki/template", + ) + client.create_page.return_value = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="b" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + with patch.object( + sys, + "argv", + [ + "harness.py", + "archive", + "14", + "按需导出", + "--config", + str(config_path), + ], + ): + result = harness_main() + self.assertEqual(config_path.read_text(encoding="utf-8"), original) + self.assertEqual(result, 0) + client.create_page.assert_called_once() + + def test_task_target_uses_stable_safe_name(self) -> None: + with tempfile.TemporaryDirectory() as directory: + target = task_target("Task-14-修复:导出", Path(directory)) + self.assertEqual(target.name, "14-修复-导出.md") + + def test_existing_mirror_keeps_historical_custom_filename(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "docs" / "task" / "2-初级维护者文档体系.md" + path.parent.mkdir(parents=True) + page = WikiPage( + title="Task-2-Junior-Maintainer-Docs", + sub_url="Task-2-Junior-Maintainer-Docs.-", + text="# 2 文档\n", + revision="c" * 40, + html_url="http://gitea.example/wiki/task-2", + ) + path.write_text(render_mirror(page), encoding="utf-8") + mirrors = existing_task_mirrors(root) + self.assertEqual( + mirrors["Task-2-Junior-Maintainer-Docs"].name, + "2-初级维护者文档体系.md", + ) + + def test_existing_mirrors_ignore_frozen_pre_wiki_archives(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = root / "docs" / "task" + task_dir.mkdir(parents=True) + legacy = task_dir / "10-历史归档.md" + legacy.write_text( + "# 10 历史归档\n\n这是接入 Wiki 前的冻结文件。\n", + encoding="utf-8", + ) + + self.assertEqual(existing_task_mirrors(root), {}) + self.assertTrue(legacy.is_file()) + + @patch("harness.dirty_paths", return_value=[]) + def test_incremental_export_skips_same_revision(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "docs" / "task" / "14-按需导出.md" + path.parent.mkdir(parents=True) + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="d" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + path.write_text(render_mirror(page), encoding="utf-8") + client = Mock() + client.list_pages.return_value = [ + { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + ] + messages = export_task_archives(client, root=root) + self.assertTrue(messages[0].startswith("跳过:")) + client.get_page_from_metadata.assert_not_called() + + @patch( + "harness.dirty_paths", + return_value=[" M docs/task/14-按需导出.md"], + ) + def test_export_stops_before_wiki_read_when_task_mirror_is_dirty( + self, _dirty + ) -> None: + client = Mock() + with self.assertRaisesRegex(WikiDocsError, "未提交改动"): + export_task_archives(client) + client.list_pages.assert_not_called() + + @patch("harness.dirty_paths", return_value=[]) + def test_full_export_reads_all_and_never_deletes_extra_file(self, _dirty) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + task_dir = root / "docs" / "task" + task_dir.mkdir(parents=True) + extra = task_dir / "99-历史快照.md" + extra_page = WikiPage( + title="Task-99-历史快照", + sub_url="Task-99.-", + text="# 99 历史快照\n", + revision="e" * 40, + html_url="http://gitea.example/wiki/task-99", + ) + extra.write_text(render_mirror(extra_page), encoding="utf-8") + page = WikiPage( + title="Task-14-按需导出", + sub_url="Task-14.-", + text="# 14 按需导出\n", + revision="f" * 40, + html_url="http://gitea.example/wiki/task-14", + ) + client = Mock() + metadata = { + "title": page.title, + "sub_url": page.sub_url, + "last_commit": {"sha": page.revision}, + } + client.list_pages.return_value = [metadata] + client.get_page_from_metadata.return_value = page + messages = export_task_archives(client, export_all=True, root=root) + exported = root / "docs" / "task" / "14-按需导出.md" + self.assertTrue(exported.is_file()) + self.assertTrue(extra.is_file()) + self.assertTrue(messages[0].startswith("已导出:")) + client.get_page_from_metadata.assert_called_once_with(metadata, page.title) + + +if __name__ == "__main__": + unittest.main() diff --git a/wiki-docs.json b/wiki-docs.json new file mode 100644 index 0000000..559fbb6 --- /dev/null +++ b/wiki-docs.json @@ -0,0 +1,25 @@ +{ + "schema_version": 1, + "gitea_url": "https://git.ilapage.cn", + "owner": "OPC", + "repository": "cmautobuy", + "dev_harness_source": { + "repository": "D:/OPC/dev_harness", + "commit": "0b6ec7675dfc2d930a30527eddac83f4302d0879", + "adopted_on": "2026-08-26" + }, + "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": "Product-Requirements-Overview", "path": "docs/09-product-requirements-overview.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"} + ] +}