diff --git a/.gitea/PULL_REQUEST_TEMPLATE.md b/.gitea/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..a4efc9c --- /dev/null +++ b/.gitea/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,68 @@ +## 变更摘要 + +简要描述本次 PR 的目的和主要变更(改了哪些页面/组件/API,为什么改)。 + +## 关联 Issue + +关闭:# + +> 仅当该 Issue 的验收标准/任务清单已全部完成时才可关闭。无关联 Issue 时填 `N/A` 并说明来源(评审快照、缺陷单等)。 + +## 提交前检查 + +> **硬性要求**:提交 PR 前必须通过 `bash scripts/check-pr-checklist.sh <本描述文件>`,确保模板三节完整且所有 checklist 子项均已勾选。 +> 不适用的子项**仍须勾选**并在行尾注明 `N/A:<理由>`;留空未勾选即视为门禁未过。 + +## 全链路检查 + +跨层变更(页面 ↔ 组件 ↔ CMS 内容模型 ↔ seed ↔ 测试)必须完成以下检查: + +- [ ] `npm run type-check` 通过(0 error) +- [ ] `npm run lint` 通过:0 error,且 warning 数未新增(须与改动前基线逐行比对,不接受「本来就有一堆告警」) +- [ ] 路由/链接真实:本次新增或改动的每个 `href`、`Link` 目标均已确认真实存在,无 `href="#"` 死链、无 404 死路 +- [ ] 涉及 CMS 内容模型:字段定义(`src/lib/cms/content-types.ts`)与 seed 已同步,并已说明**是否需要重跑 `npm run db:seed`**(DB 写入须单独授权,不得静默假定已生效) +- [ ] 涉及视觉/交互改动:已在**新起的** `next dev`(如 `-p 3001`)上用浏览器复核,未把 :3000 的 `next start` 旧生产预览当作现状 +- [ ] 暗黑模式:新增样式使用语义 token(`bg-bg-*` / `text-ink` / `text-text-*` / `border-border-*`),无裸 `bg-white`、`text-white`、`gray-*` 硬编码 +- [ ] 动效合规:入场时长落在 180–280ms、曲线 `ease-ink` `[0.22, 1, 0.36, 1]`,stagger 步进 ≤ 60ms +- [ ] 品牌红 `#C41E3A`:每页 ≥ 3 处触达点,覆盖面积 ≤ 10% +- [ ] 数字与宣称口径:结果型数字带 `basis`(缺失/非法一律按最弱 `target` 处理并自动附角注);无「源自真实客户案例」「实战验证」等未证实佐证(以 `FORBIDDEN_PROOF_PHRASES` 守卫为准) +- [ ] 无遗留 `console.log` / `TODO` / `FIXME` / `.only()` + +## 测试分层检查(L0–L3) + +> 参考 `docs/testing.md` 与 `docs/testing-guide.md` + +- [ ] **L0 单元**:新增/修改的工具函数、Hook、CMS 渲染器已补对应 jest 用例 +- [ ] **L1 组件**:新增/修改的 React 组件已覆盖主渲染路径与关键交互(含空态/兜底分支) +- [ ] **L2 E2E**:涉及关键用户路径(导航、表单、Hero 可见性)的改动已通过 `npm run test:e2e:fast` +- [ ] **L3 视觉回归**:改了 UI 样式的,已跑 `npm run test:visual`(必要时 `test:visual:update` 并逐张核对 diff,不接受盲更新快照) +- [ ] 新增机械守卫测试均带**正控制**(构造一个必然命中的样例),杜绝「匹配零」的假绿 +- [ ] 已运行相关测试并全绿,无 `.only()` / 跳过态残留 + +## 质量门禁 + +- [ ] `npm run test:unit` 全部通过 +- [ ] `npm run test:coverage` 通过 `jest.config.js` 的 `coverageThreshold`(global:branches 30% / functions 25% / lines 32% / statements 30%),且**未下调阈值** +- [ ] `npm run test`(Playwright E2E)全部通过 +- [ ] `npm run check:contrast` 与 `npm run check:headings` 通过(WCAG 2.1 AA) +- [ ] `npm run lighthouse` 满足 `lhci` 断言(性能/CSP/可访问性预算未回退) +- [ ] `npm run test:security:headers` 通过(若改动涉及响应头、CSP 或部署配置) +- [ ] 文档已同步:`README.md` / `CONTEXT.md` / `CLAUDE.md` / `DESIGN.md` / `PRODUCT.md` 及 `docs/` 下受影响文件;代码注释给出决策佐证来源 +- [ ] 提交信息符合 Conventional Commits,且每个 commit 是可独立评审的垂直切片 +- [ ] 破坏性/共享状态动作(seed 重跑、DB 写入、force push、合并、删分支、部署)已单独取得授权,未夹带在常规变更里 + +## 假绿灯纪律 + +> 测试全绿 ≠ 功能可用。以下条目用于区分「验证过了」与「看起来验证过了」。 + +- [ ] 门禁命令**实际执行过**并粘贴真实输出(失败口径如实记录),未以「理论上应该没问题」代替运行 +- [ ] 未通过删测试、放宽断言、`skip`/`todo` 用例、下调阈值等方式让门禁变绿 +- [ ] 未把误报(false positive)当问题「修掉」——若判定为假阳,已记录判定依据与不改的理由 +- [ ] 结论以 `origin/*` 与实测为准,未用本地陈旧 ref(如落后的本地 `dev`)推断分支/历史事实 +- [ ] UI 改动已人工复核关键与边界态;无法复核时已在 PR 中明确写出「未验证项」而非沉默 + +## 其他说明 + +补充截图、性能数据、兼容性说明、未验证项清单,或需要评审者特别关注的事项。 + +> **提交前执行**:`bash scripts/check-pr-checklist.sh` 验证所有 checklist 子项已勾选。 diff --git a/CLAUDE.md b/CLAUDE.md index 620071b..2b6bcf9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -202,6 +202,11 @@ The project builds to `dist/` (configured via `distDir` in `next.config.mjs`). I ### Commit Convention Uses Conventional Commits with commitlint (`@commitlint/config-conventional`). Husky + lint-staged enforces linting on pre-commit. +### Submission Flow (PR-First) +`dev`/`main` are only reached via feature branch + PR: sync `origin/dev` → rebase → push → PR gate → merge by **Rebase** (linear history, no merge commit). +`bash scripts/check-pr-checklist.sh ` must pass before opening a PR — it verifies `.gitea/PULL_REQUEST_TEMPLATE.md` has the three required sections (全链路检查 / 测试分层检查 / 质量门禁) with ≥20 checklist items, and that every item in the PR body is checked (mark irrelevant ones as `N/A:`). +Rebase rewrites hashes: an already-pushed branch may only be updated with `--force-with-lease`. Always rebase onto `origin/dev`, never a possibly-stale local `dev`. + ### Component Versioning History The project has gone through multiple design iterations. Older component versions: - `components/detail-v2/` — previous iteration (deleted per git status, migration to `detail/` completed) diff --git a/docs/development/quality-gates.md b/docs/development/quality-gates.md index 519e772..d5cbc12 100644 --- a/docs/development/quality-gates.md +++ b/docs/development/quality-gates.md @@ -96,6 +96,23 @@ Closes #123 **通过标准**: 无类型错误 +## 提交与 PR 流程(PR-First) + +`dev` / `main` 只接收来自功能分支的 PR,执行顺序固定:**同步 dev → rebase → push → PR 门禁 → rebase 合并**。 + +```bash +git fetch origin dev && git rebase origin/dev # 必须以 origin/dev 为准,本地 dev 可能已陈旧 +git push -u origin # rebase 改写哈希后仅用 --force-with-lease,禁裸 --force +bash scripts/check-pr-checklist.sh # 未通过不得创建 PR +``` + +- PR 模板:`.gitea/PULL_REQUEST_TEMPLATE.md`(三节:全链路检查 / 测试分层检查 / 质量门禁)。 +- 门禁校验:模板三节完整 + checklist 子项 ≥ 20 + 描述文件所有子项已勾选;不适用项**仍须勾选**并注明 `N/A:理由`。 +- 功能分支合并方式统一选 Gitea 的 **Rebase**(保持线性历史),不选 Create merge commit。 +- 破坏性/共享状态动作(seed 重跑、DB 写入、force push、合并、删分支、部署)须单独取得授权,不夹带在常规变更里。 + +> 注:`AGENTS.md` 被 `.gitignore` 排除(由 `next dev` 再生),本节是该流程在版本库中的权威副本。 + ## 如何绕过质量门禁 ⚠️ **警告**: 仅在紧急情况下绕过质量门禁 diff --git a/scripts/check-pr-checklist.sh b/scripts/check-pr-checklist.sh new file mode 100755 index 0000000..2a7e372 --- /dev/null +++ b/scripts/check-pr-checklist.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# check-pr-checklist.sh — PR 提交门禁检查(对齐 AGENTS.md §5 质量门禁 / §6 文档同步清单) +# +# 检查 PR 描述文件中 checklist 子项的勾选完成情况,以及 PR 模板的结构完整性。 +# 规则: +# ① PR 模板必须包含「全链路检查」「测试分层检查」「质量门禁」三节 +# ② PR 描述文件中所有 checklist 子项(- [ ])必须已勾选(不适用项勾选并注明 N/A 理由) +# +# 用法: +# bash scripts/check-pr-checklist.sh # 检查模板结构 +# bash scripts/check-pr-checklist.sh # 检查 PR 描述文件 +# bash scripts/check-pr-checklist.sh --pr-dir <目录> # 扫描目录下所有 PR 文件 +# bash scripts/check-pr-checklist.sh --quiet # 仅违规时输出 +# bash scripts/check-pr-checklist.sh --help +# +# 退出码: 0=全部通过 1=发现违规 2=模板不存在/格式错误 +set -euo pipefail + +# 说明:AGENT_PROJECT_DIR 供测试与多工作树场景覆写模板根目录;默认取脚本所在仓库根, +# 因此从任意子目录调用都能定位到 .gitea/PULL_REQUEST_TEMPLATE.md。 +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEMPLATE_FILE="${AGENT_PROJECT_DIR:-$REPO_ROOT}/.gitea/PULL_REQUEST_TEMPLATE.md" + +QUIET=0 +PR_FILE="" +SCAN_DIR="" + +# 采用 while+shift 而非 for:for arg in "$@" 里 shift 不会跳过已消费的值, +# `--pr-dir <目录>` 会把 <目录> 再当成一次位置参数误判为 PR 文件。 +while [ $# -gt 0 ]; do + case "$1" in + --help | -h) + echo "check-pr-checklist.sh — PR 提交门禁检查" + echo "" + echo "用法:" + echo " bash scripts/check-pr-checklist.sh # 检查模板结构" + echo " bash scripts/check-pr-checklist.sh # 检查 PR 描述文件" + echo " bash scripts/check-pr-checklist.sh --pr-dir <目录> # 扫描目录下所有 PR 文件" + echo " bash scripts/check-pr-checklist.sh --quiet # 仅违规时输出" + echo "" + echo "检查项:" + echo " - PR 模板结构完整性(三节必填 + checklist 子项 ≥ 20)" + echo " - PR 描述文件中所有 checklist 子项均已勾选" + exit 0 + ;; + --quiet | -q) QUIET=1 ;; + --pr-dir) + if [ $# -lt 2 ]; then + echo "❌ --pr-dir 需要一个目录参数" >&2 + exit 2 + fi + SCAN_DIR="$2" + shift + ;; + -*) + echo "❌ 未知选项: $1" >&2 + exit 2 + ;; + *) + if [ -n "$PR_FILE" ]; then + echo "❌ 只接受一个 PR 描述文件,多余参数: $1" >&2 + exit 2 + fi + PR_FILE="$1" + ;; + esac + shift +done + +violations=() + +# ── 读取 PR 描述正文:仅在文件确实以 YAML frontmatter 开头时才剥离 ── +# 若无 frontmatter 也无条件剥离,awk 的 fm 永远 <2 → 正文为空 → 三个标准节全部 +# 「未发现」→ 任何普通描述都被误判为不是 PR 描述文件。故按首行判别并加空正文兜底。 +read_pr_body() { + local file="$1" body="" + if [ "$(head -1 "$file" 2>/dev/null)" = "---" ]; then + body=$(awk 'BEGIN{fm=0} /^---$/{fm++; next} fm>=2{print}' "$file" 2>/dev/null || true) + fi + [ -n "$body" ] || body=$(cat "$file") + printf '%s\n' "$body" +} + +report_and_exit() { + local ok_msg="$1" + if [ "${#violations[@]}" -gt 0 ]; then + [ "$QUIET" -eq 0 ] && echo "❌ ${2}:" + printf '%s\n' "${violations[@]}" >&2 + exit 1 + fi + [ "$QUIET" -eq 0 ] && echo "✅ ${ok_msg}" + exit 0 +} + +# ── 模式 1: 扫描目录 ── +if [ -n "$SCAN_DIR" ]; then + if [ ! -d "$SCAN_DIR" ]; then + [ "$QUIET" -eq 0 ] && echo "⚠ PR 目录不存在: ${SCAN_DIR}(跳过)" >&2 + exit 0 + fi + while IFS= read -r f; do + [ -f "$f" ] || continue + body=$(read_pr_body "$f") + unchecked=$(printf '%s\n' "$body" | grep -c '^- \[ \]' || true) + if [ "$unchecked" -gt 0 ]; then + violations+=("$(basename "$f"): 存在 ${unchecked} 个未勾选子项——所有 checklist 子项须勾选后才可提交 PR") + fi + done < <(find "$SCAN_DIR" -name '*.md' -type f | sort) + report_and_exit "PR 描述文件门禁通过:所有文件子项均已勾选" "PR 描述文件门禁未通过" +fi + +# ── 模式 2: 检查单个 PR 描述文件 ── +if [ -n "$PR_FILE" ]; then + if [ ! -f "$PR_FILE" ]; then + echo "❌ PR 描述文件不存在: $PR_FILE" >&2 + exit 2 + fi + body=$(read_pr_body "$PR_FILE") + unchecked=$(printf '%s\n' "$body" | grep -c '^- \[ \]' || true) + + has_section1=$(printf '%s\n' "$body" | grep -c '全链路检查' || true) + has_section2=$(printf '%s\n' "$body" | grep -c '测试分层检查' || true) + has_section3=$(printf '%s\n' "$body" | grep -c '质量门禁' || true) + + if [ "$has_section1" -eq 0 ] && [ "$has_section2" -eq 0 ] && [ "$has_section3" -eq 0 ]; then + violations+=("$(basename "$PR_FILE"): 未发现 PR 模板标准节(全链路检查/测试分层检查/质量门禁)——可能不是 PR 描述文件,或模板结构被修改") + fi + + if [ "$unchecked" -gt 0 ]; then + violations+=("$(basename "$PR_FILE"): 存在 ${unchecked} 个未勾选子项——所有 checklist 子项须勾选后才可提交 PR") + fi + + report_and_exit "PR 描述文件门禁通过:所有子项均已勾选" "PR 描述文件门禁未通过" +fi + +# ── 模式 3: 检查 PR 模板结构完整性(默认模式)── +if [ ! -f "$TEMPLATE_FILE" ]; then + echo "❌ PR 模板文件不存在: $TEMPLATE_FILE" >&2 + exit 2 +fi + +required_sections=("全链路检查" "测试分层检查" "质量门禁") +missing_sections=() + +for section in "${required_sections[@]}"; do + if ! grep -q "^##.*${section}" "$TEMPLATE_FILE"; then + missing_sections+=("${section}") + fi +done + +if [ "${#missing_sections[@]}" -gt 0 ]; then + sections_str=$(IFS=, ; echo "${missing_sections[*]}") + violations+=("PR 模板缺失必要节: ${sections_str}(PR 模板须包含全链路检查/测试分层检查/质量门禁三节)") +fi + +checklist_count=$(grep -c '^- \[ \]' "$TEMPLATE_FILE" || true) +if [ "$checklist_count" -lt 20 ]; then + violations+=("PR 模板中 checklist 子项数量异常(仅 ${checklist_count} 项,预期 ≥ 20 项)——模板可能被意外修改") +fi + +report_and_exit "PR 模板结构门禁通过:${checklist_count} 项子项,三节完整" "PR 模板结构门禁未通过"