From ea0072292c548f007c38d3389abf173e44e779f9 Mon Sep 17 00:00:00 2001 From: Redamancy <848238014@qq.com> Date: Wed, 9 Sep 2026 01:00:07 +0800 Subject: [PATCH] =?UTF-8?q?:books:=20docs(governance):=20=E5=BC=BA?= =?UTF-8?q?=E5=8C=96=20Issue/PR=20=E5=85=83=E6=95=B0=E6=8D=AE=E7=BC=96?= =?UTF-8?q?=E7=A0=81=E4=B8=8E=E8=AF=81=E6=8D=AE=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pull_request_template.md | 22 ++++ CONTRIBUTING.md | 20 +++- docs/README.md | 1 + docs/engineering-standards.md | 5 +- docs/governance/historical-metadata.md | 40 +++++++ scripts/validate_commit_title.py | 149 ++++++++++++++++++++++--- tests/unit/test_commit_metadata.py | 97 ++++++++++++++++ 7 files changed, 314 insertions(+), 20 deletions(-) create mode 100644 docs/governance/historical-metadata.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3171234..572cbdc 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -45,3 +45,25 @@ $ <实际执行的命令> - [ ] 新功能或缺陷修复有测试 - [ ] 未提交密钥、凭据、生成物或本地配置 - [ ] CI 已通过,阻塞性评论已解决 + +## 阶段性回帖模板 + +至少在“实现完成”和“验证完成”两个里程碑各发布一条事实回帖。回帖应记录可复现的输入、环境、命令、原始结果和未证明的边界;不要粘贴凭据、生产数据或未执行检查的“通过”结论。 + +```markdown +## <实现完成或验证完成>(YYYY-MM-DD) + +### 当前结论 +<已证明的结论,以及尚未证明的部分> + +### 变更与设计取舍 +- 提交:`` +- <涉及模块、替代方案和选择理由> + +### 验证与证据 +- `<实际执行的命令>`:<原始结果或报告路径> +- 环境与输入:<版本、fixture、脱敏配置> + +### 兼容性与下一步 +- <风险、未运行检查、回滚方式或后续 Issue> +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8580630..0a99cc0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,8 +8,9 @@ 2. 从 `main` 创建带 Issue 编号的短分支。 3. 小步提交,遵循统一的 Gitmoji + Conventional Commits 标题和正文格式。 4. 本地运行相关测试和 lint/format 检查。 -5. 创建 PR,关联 Issue,并填写 PR 模板中的测试、实验结果、风险和回滚信息。 -6. 等待 CI 和审查通过后合并;不要直接向 `main` 推送功能代码。 +5. 创建 PR,使用 `Closes #123` 或 `Refs #123` 关联 Issue,并填写 PR 模板的七个固定章节。 +6. 在实现完成和验证完成两个里程碑发布事实回帖,记录输入、环境、命令、原始结果、设计取舍、边界、风险和回滚。 +7. 等待 CI 和审查通过后合并;不要直接向 `main` 推送功能代码。 Issue 和 PR 的标题、正文描述尽量使用中文,便于项目协作者审查和追踪。Gitmoji、commit 类型、代码标识、命令和 API 名称保留原文。 @@ -36,4 +37,17 @@ PYTHONPATH=src uv run python scripts/verify_java_compile.py :test_tube: test(generator): 覆盖可空枚举字段 ``` -正文依次写 `背景`、`变更`、`验证`、`实验`、`风险`。PR 标题和首个提交标题必须相同风格;PR 额外关联 Issue 并填写审查清单。 +Issue 正文必须填写对应表单要求的环境/问题、方案、验收标准、测试计划和风险章节。PR 正文固定使用: +`关联 Issue`、`背景(Situation)`、`任务(Task)`、`行动(Action)`、`验证(Verification)`、 +`实验与证据(Evidence)`、`兼容性、风险与回滚`。每个章节都要有实际内容;不要留下 `<...>`、`TODO`、 +连续 `??`、U+FFFD、字面量 `\\n` 或 `\\r`。命令、原始结果和未执行检查必须如实记录,且先脱敏再粘贴。 + +校验器可在本地复用: + +```bash +python scripts/validate_commit_title.py --title ":books: docs(governance): 更新协作规范" +python scripts/validate_commit_title.py --pr-event event.json +python scripts/validate_commit_title.py --issue-event event.json +``` + +提交后不需要轮询 CI;只有准备 squash merge 前,才检查最新提交的全部 required checks。 diff --git a/docs/README.md b/docs/README.md index 7684179..cfad4f8 100644 --- a/docs/README.md +++ b/docs/README.md @@ -33,6 +33,7 @@ | [MCP服务测试提示词.md](MCP服务测试提示词.md) | MCP服务测试的提示词和使用指南 | MCP服务测试 | | [development.md](development.md) | 开发指南和常见问题解答 | 开发者、贡献者 | | [engineering-standards.md](engineering-standards.md) | Issue、PR、提交、回帖和质量门禁规范 | 维护者、贡献者 | +| [governance/historical-metadata.md](governance/historical-metadata.md) | 历史元数据乱码审计与可编辑展示修复记录 | 维护者、审查者 | | [mybatis依赖分析.md](mybatis依赖分析.md) | MyBatis相关依赖的详细分析 | MyBatis模板使用 | | [依赖分析.md](依赖分析.md) | 项目依赖分析和管理策略 | 依赖管理 | | [启动方式.md](启动方式.md) | 项目启动方式的详细说明 | 项目部署、运行 | diff --git a/docs/engineering-standards.md b/docs/engineering-standards.md index 1ebde0d..ca0e4d4 100644 --- a/docs/engineering-standards.md +++ b/docs/engineering-standards.md @@ -65,8 +65,9 @@ Issue 可以先描述问题,但同样必须使用标题语法,不能使用 ` - 推送前修正本分支中错误的未合并提交;已经合并的历史不强制重写,以后续、单独的维护提交 覆盖其文件元数据。 -CI 校验 PR 标题、PR 内全部提交标题和 PR 正文的必备章节。它只检查结构和编码信号,不会把 -测试或性能结论当成已经证明的事实;审查者仍须检查命令、输出和声明是否匹配。 +CI 校验 PR 标题、PR 内全部提交标题和 PR 正文的必备章节;`scripts/validate_commit_title.py` +还提供 Issue 正文校验入口,供本地审计或后续 Issue workflow 复用。校验器只检查结构和编码信号, +不会把测试或性能结论当成已经证明的事实;审查者仍须检查命令、输出和声明是否匹配。 ## 3. Issue 要求 diff --git a/docs/governance/historical-metadata.md b/docs/governance/historical-metadata.md new file mode 100644 index 0000000..2ad9e43 --- /dev/null +++ b/docs/governance/historical-metadata.md @@ -0,0 +1,40 @@ +# 历史元数据审计记录 + +本页记录 2026-09-09 治理工作中发现的编码和展示问题。它是审计说明,不代表已经重写 Git 历史;合并后的 commit object 保持原样,通过后续提交和 GitHub 可编辑字段修复可见信息。 + +## 不可变 commit object + +以下提交的 subject 或展示文本曾包含连续问号、乱码或无上下文标题。它们已经进入历史,不能安全地改写 hash;后续审查应以对应提交的 diff、测试和本页说明为准。 + +| Commit | 历史展示问题 | +| --- | --- | +| `cf03c4b` | `:recycle: refactor(model): ??????????` | +| `603c598` | `:books: docs(governance): ?????????????` | +| `b60c4da` | `:bug: fix(codegen): ?? PostgreSQL ?????` | +| `c482fc1` | `:bug: fix(metadata): ?? PostgreSQL ??????` | +| `0ccbd15` | `:lock: security(codegen): ??????????` | +| `cc45e02` | `:recycle: refactor(codegen): ???????????? (#34)` | +| `a60bba6` | `:recycle: refactor(visualization): ??????????` | +| `ae79e66` | `:recycle: refactor(codegen): ????????????` | +| `a994e8c` | 旧英文/问号治理正文 | +| `46d1b2b` | 旧英文/问号 CI 正文 | +| `fd39727` | `init` | +| `18f7deb` | `Initial commit` | +| `06e0f8a` | 主页曾显示的 `feat(algorithms): schema_topo - Kahn 拓扑排序` | + +## 已修复的 GitHub 展示元数据 + +以下对象的 title/body 可编辑,因此已通过 GitHub API 修复字面量伪换行、乱码和章节格式。修复不改变对应 commit hash 或 diff: + +- PR body:`#78`、`#90`、`#92`、`#94`、`#96`、`#98`、`#100`、`#102`、`#104`、`#106`、`#109`、`#110`、`#112`、`#113`、`#115`、`#126`、`#131`、`#148`。 +- Issue body:`#77`、`#125`、`#127`、`#135`。 +- Title:PR/Issue `#3`、`#4`、`#112`。 +- 主页仓库路径元数据:由提交 `2803b403` 统一。 + +修复后的正文仍需通过本地 `validate_pr_body()` 或 `validate_issue_body()` 检查;缺少实验输入、原始输出、边界或回滚信息的历史内容不能追溯补造,只能在后续回帖中补充真实证据。 + +## 审计边界 + +- 不在本页粘贴 token、密码、连接串、生产数据或完整数据库输出。 +- 不把未运行的 CI、性能实验或真实数据库验证写成“通过”。 +- 新 Issue/PR 使用模板并在合并前完成一次 required checks 核对;单次提交后不轮询 CI。 diff --git a/scripts/validate_commit_title.py b/scripts/validate_commit_title.py index dd47c83..c81eacf 100644 --- a/scripts/validate_commit_title.py +++ b/scripts/validate_commit_title.py @@ -30,13 +30,54 @@ ) ISSUE_REFERENCE_PATTERN = re.compile(r"\b(?:Closes|Refs)\s+#\d+\b", re.IGNORECASE) REQUIRED_PR_SECTIONS = ( - "## 关联 Issue", - "## 背景(Situation)", - "## 任务(Task)", - "## 行动(Action)", - "## 验证(Verification)", - "## 实验与证据(Evidence)", - "## 兼容性、风险与回滚", + "关联 Issue", + "背景(Situation)", + "任务(Task)", + "行动(Action)", + "验证(Verification)", + "实验与证据(Evidence)", + "兼容性、风险与回滚", +) +ISSUE_SECTION_SETS = ( + ( + "版本与环境", + "问题与预期行为", + "最小复现", + "验收标准", + "非目标、风险与安全", + ), + ( + "问题与用户价值", + "建议方案与替代方案", + "验收标准", + "架构、兼容性与测试计划", + "非目标与风险", + ), + ( + "问题与用户价值", + "建议方案与非目标", + "验收标准", + "架构、兼容性与测试计划", + "非目标与风险", + ), + ( + "环境信息", + "问题与预期行为", + "复现步骤", + "验收标准", + "非目标、风险与安全信息", + ), +) +HEADING_PATTERN = re.compile(r"(?m)^#{2,6}\s+(?P[^\r\n#]+?)\s*$") +PLACEHOLDER_PATTERN = re.compile( + r"<\s*(?:[^>\r\n]{1,80})\s*>|\b(?:TODO|TBD|FIXME)\b|请填写|待填写|按模块列出", + re.IGNORECASE, +) +CONTROL_CHARACTER_PATTERN = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]") +PSEUDO_NEWLINE_PATTERN = re.compile(r"\\[nr]") +GENERIC_TITLE_PATTERN = re.compile( + r"^(?:config|init|\.gitignore|initial commit|请填写(?:标题|摘要)?)$", + re.IGNORECASE, ) @@ -46,8 +87,18 @@ def validate_title(title: str) -> list[str]: normalized = title.rstrip("\r\n") if len(normalized) > 72: errors.append("title exceeds 72 characters") + if "\ufffd" in normalized: + errors.append("title contains the Unicode replacement character; check UTF-8 encoding") if "??" in normalized: errors.append("title contains consecutive '?' characters; check UTF-8 encoding") + if PSEUDO_NEWLINE_PATTERN.search(normalized): + errors.append("title contains a literal \\n or \\r escape; use real line structure") + if CONTROL_CHARACTER_PATTERN.search(normalized): + errors.append("title contains a control character") + if GENERIC_TITLE_PATTERN.fullmatch(normalized.strip()) or PLACEHOLDER_PATTERN.search( + normalized + ): + errors.append("title contains a generic or unreplaced placeholder subject") match = TITLE_PATTERN.fullmatch(normalized) if not match: errors.append("expected ':gitmoji: type(scope): imperative subject'") @@ -57,6 +108,8 @@ def validate_title(title: str) -> list[str]: errors.append(f"unsupported Gitmoji {match.group('emoji')}") elif match.group("type") != expected_type: errors.append(f"Gitmoji {match.group('emoji')} must use type {expected_type}") + if normalized.endswith(("。", ".")): + errors.append("title must not end with sentence punctuation") return errors @@ -64,16 +117,67 @@ def validate_pr_body(body: str | None) -> list[str]: """Return structural and encoding-policy violations for a PR body.""" normalized = body or "" errors: list[str] = [] - if "??" in normalized: - errors.append("PR body contains consecutive '?' characters; check UTF-8 encoding") - if not ISSUE_REFERENCE_PATTERN.search(normalized): + errors.extend(_validate_text_quality(normalized, "PR body")) + content = _without_comments(normalized) + if not ISSUE_REFERENCE_PATTERN.search(content): errors.append("PR body must contain 'Closes #<number>' or 'Refs #<number>'") - for section in REQUIRED_PR_SECTIONS: - if section not in normalized: - errors.append(f"PR body is missing required section: {section}") + _validate_sections(content, REQUIRED_PR_SECTIONS, "PR body", errors) + return errors + + +def validate_issue_body(body: str | None) -> list[str]: + """Return structural and encoding-policy violations for an Issue body.""" + normalized = body or "" + errors = _validate_text_quality(normalized, "Issue body") + content = _without_comments(normalized) + headings = {match.group("title").strip() for match in HEADING_PATTERN.finditer(content)} + matching_schema = next( + (schema for schema in ISSUE_SECTION_SETS if set(schema) <= headings), None + ) + if matching_schema is None: + errors.append("Issue body does not contain a complete bug or feature section set") + return errors + _validate_sections(content, matching_schema, "Issue body", errors) + return errors + + +def _without_comments(text: str) -> str: + return re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL) + + +def _validate_text_quality(text: str, label: str) -> list[str]: + errors: list[str] = [] + if "\ufffd" in text: + errors.append(f"{label} contains the Unicode replacement character; check UTF-8 encoding") + if "??" in text: + errors.append(f"{label} contains consecutive '?' characters; check UTF-8 encoding") + if PSEUDO_NEWLINE_PATTERN.search(text): + errors.append(f"{label} contains literal \\n or \\r escapes; use real line breaks") + if CONTROL_CHARACTER_PATTERN.search(text): + errors.append(f"{label} contains a control character") + if PLACEHOLDER_PATTERN.search(_without_comments(text)): + errors.append(f"{label} contains an unreplaced template placeholder") return errors +def _validate_sections(text: str, sections: Iterable[str], label: str, errors: list[str]) -> None: + headings = list(HEADING_PATTERN.finditer(text)) + positions = {match.group("title").strip(): match for match in headings} + previous_position = -1 + for section in sections: + match = positions.get(section) + if match is None: + errors.append(f"{label} is missing required section: ## {section}") + continue + if match.start() < previous_position: + errors.append(f"{label} sections are out of order: ## {section}") + previous_position = match.start() + next_heading = next((item for item in headings if item.start() > match.start()), None) + section_body = text[match.end() : next_heading.start() if next_heading else None].strip() + if not section_body or PLACEHOLDER_PATTERN.search(section_body): + errors.append(f"{label} section is empty: ## {section}") + + def _titles_from_range(rev_range: str) -> list[str]: result = subprocess.run( ["git", "log", "--format=%s", rev_range], @@ -107,10 +211,14 @@ def main(argv: list[str] | None = None) -> int: "--pr-event", help="GitHub pull_request event payload used to validate the PR body.", ) + parser.add_argument( + "--issue-event", + help="GitHub issues event payload used to validate the Issue body.", + ) args = parser.parse_args(argv) titles = list(_iter_titles(args)) - if not titles and not args.pr_event: - parser.error("provide --title, --range, and/or --pr-event") + if not titles and not args.pr_event and not args.issue_event: + parser.error("provide --title, --range, --pr-event, and/or --issue-event") failures = 0 for title in titles: @@ -133,6 +241,17 @@ def main(argv: list[str] | None = None) -> int: print(f" - {error}", file=sys.stderr) else: print("OK: pull request body") + if args.issue_event: + with open(args.issue_event, encoding="utf-8") as event_file: + event = json.load(event_file) + body_errors = validate_issue_body(event.get("issue", {}).get("body")) + if body_errors: + failures += 1 + print("INVALID: issue body", file=sys.stderr) + for error in body_errors: + print(f" - {error}", file=sys.stderr) + else: + print("OK: issue body") return 1 if failures else 0 diff --git a/tests/unit/test_commit_metadata.py b/tests/unit/test_commit_metadata.py index 3392ca5..3f0cb2c 100644 --- a/tests/unit/test_commit_metadata.py +++ b/tests/unit/test_commit_metadata.py @@ -7,6 +7,7 @@ _POLICY = runpy.run_path(str(Path(__file__).parents[2] / "scripts" / "validate_commit_title.py")) validate_title = _POLICY["validate_title"] validate_pr_body = _POLICY["validate_pr_body"] +validate_issue_body = _POLICY["validate_issue_body"] VALID_PR_BODY = """## 关联 Issue @@ -80,6 +81,24 @@ def test_rejects_likely_encoding_corruption(): assert any("UTF-8 encoding" in error for error in errors) +def test_rejects_replacement_character_pseudo_newline_and_placeholder_title(): + replacement_errors = validate_title(":books: docs(governance): 修复\ufffd") + pseudo_newline_errors = validate_title(":books: docs(governance): 修复\\n占位") + placeholder_errors = validate_title(":books: docs(governance): <请填写摘要>") + + assert any("replacement character" in error for error in replacement_errors) + assert any("literal" in error for error in pseudo_newline_errors) + assert any("placeholder" in error for error in placeholder_errors) + + +def test_rejects_generic_title_and_sentence_punctuation(): + assert validate_title("init") + assert any( + "sentence punctuation" in error + for error in validate_title(":books: docs(readme): 更新说明。") + ) + + def test_accepts_complete_pr_body(): assert validate_pr_body(VALID_PR_BODY) == [] @@ -89,3 +108,81 @@ def test_rejects_incomplete_pr_body_and_encoding_corruption(): assert any("UTF-8 encoding" in error for error in errors) assert any("missing required section" in error for error in errors) + + +def test_rejects_empty_pr_sections_and_unreplaced_placeholders(): + body = VALID_PR_BODY.replace("固化契约。", "").replace( + "模板变为必填;回滚本提交。", "<填写风险>" + ) + + errors = validate_pr_body(body) + + assert any("section is empty" in error for error in errors) + assert any("placeholder" in error for error in errors) + + +VALID_ISSUE_BODY = """## 问题与用户价值 + +贡献者需要可追溯的元数据规范。 + +## 建议方案与替代方案 + +扩展纯 Python 校验器;不依赖网络。 + +## 验收标准 + +- [ ] 覆盖正文结构和编码信号。 + +## 架构、兼容性与测试计划 + +运行单元测试,保持运行时 API 不变。 + +## 非目标与风险 + +不重写历史提交;回滚治理提交。 +""" + + +def test_accepts_complete_issue_body(): + assert validate_issue_body(VALID_ISSUE_BODY) == [] + + +def test_accepts_issue_form_headings(): + body = """### 环境信息 + +Python 3.12;SQLite fixture。 + +### 问题与预期行为 + +描述实际和预期行为。 + +### 复现步骤 + +运行脱敏的最小命令。 + +### 验收标准 + +- [ ] 回归测试通过。 + +### 非目标、风险与安全信息 + +不包含凭据;可回滚。 +""" + + assert validate_issue_body(body) == [] + + +def test_rejects_issue_body_with_missing_sections_and_pseudo_newline(): + errors = validate_issue_body("## 问题与用户价值\\n\n说明") + + assert any("complete bug or feature section set" in error for error in errors) + assert any("literal" in error for error in errors) + + +def test_rejects_issue_body_with_empty_section_and_control_character(): + body = VALID_ISSUE_BODY.replace("扩展纯 Python 校验器;不依赖网络。", "") + "\x0b" + + errors = validate_issue_body(body) + + assert any("section is empty" in error for error in errors) + assert any("control character" in error for error in errors)