@routerhub/agent-rules 1.5.150 → 1.5.152
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.base.md +18 -1
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/rules/frontend.md +5 -0
- package/rules/global.md +1 -1
- package/rules/review-boundary.md +12 -0
- package/skills/create-pr/SKILL.md +2 -1
- package/skills/deploy-test/SKILL.md +51 -1
- package/skills/loop-review/SKILL.md +11 -15
- package/skills/loop-review/scripts/check-review.sh +64 -0
- package/skills/pr-release-loop/SKILL.md +24 -6
- package/skills/screenshot-annotate/SKILL.md +17 -8
- package/skills/screenshot-annotate/annotate.js +244 -56
- package/skills/visual-report/SKILL.md +3 -3
package/AGENTS.base.md
CHANGED
|
@@ -269,7 +269,7 @@
|
|
|
269
269
|
## 禁止项
|
|
270
270
|
|
|
271
271
|
- ⚠️ AI 禁止自动执行格式化命令(`npm run format`、`prettier` 等)。
|
|
272
|
-
- ⚠️ 禁止修改 GCLB
|
|
272
|
+
- ⚠️ 禁止修改 GCLB 配置,除用户明确确认外(原因和替代方案见下方「共享基础设施变更铁律」;获得确认后的修改操作须遵循「网关/负载均衡 404 排查规范」的 additive-only 原则)。
|
|
273
273
|
- 禁止修改核心业务文件和 API 相关代码。
|
|
274
274
|
- `.env` 仅允许配端口号,密钥/Token 等敏感信息必须配到 Nacos 配置中心。
|
|
275
275
|
|
|
@@ -361,6 +361,11 @@
|
|
|
361
361
|
## 前端规则
|
|
362
362
|
|
|
363
363
|
- ⚠️ **默认隐藏滚动条**:页面/容器出现滚动需求时,滚动条默认隐藏(内容仍可正常滚动),不得让滚动条可见。仅当用户明确说「需要滚动条」「可以有滚动条」「显示滚动条」等表述时,才允许显示。
|
|
364
|
+
- ⚠️ **依赖安装统一 `pnpm`**,禁止 `npm install` / `yarn install`(pnpm workspace 混合安装会破坏依赖结构)。
|
|
365
|
+
- ⚠️ **API 请求严格使用 OpenAPI 生成的方法,禁止手写请求或直接拼接路径**;接口变更后先更新 OpenAPI 定义并重新生成 API 代码,再进行业务开发。
|
|
366
|
+
- ⚠️ **用 `console.error` 记录错误**(含函数名/模块名上下文),禁止用 `console.log` 输出错误;提交前移除调试日志和临时代码。
|
|
367
|
+
- ⚠️ **调试过程中产生的中间产物(临时文件、测试脚本、调试截图、dump 文件、临时注释、`console.log` 等)禁止加入 Git 提交**,`.gitignore` 中应配置忽略常见中间产物。
|
|
368
|
+
- TypeScript 类型优先复用 `api` 文件夹下 `typings.d.ts`,不存在时再自定义;组件 `props` 能复用时必须优先复用。
|
|
364
369
|
|
|
365
370
|
<!-- 前端特有规则在此添加 -->
|
|
366
371
|
|
|
@@ -391,6 +396,7 @@
|
|
|
391
396
|
- 🔴 **会导致实际 bug / 数据丢失 / 数据错误 / 安全漏洞**:有明确触发路径(真实输入、真实调用链能复现),读 diff 能确认后果。
|
|
392
397
|
- 🟡 **真实风险**:有明确触发条件,且触发后后果严重(如并发竞态、错误被静默吞掉、未处理空值导致崩溃)。
|
|
393
398
|
- **违反本项目已固化规则**:AGENTS.base.md / AGENTS.private.md 里的 ⚠️ 铁律(DELETE 铁律、部署自包含、数据链路核对、环境配置禁止推断、测试环境验证前置等),diff 中明确违反 → 必须报。
|
|
399
|
+
- **外部事实核查确认的配置错误**:按「审查方法 → ④ 外部事实核查」查出的事实性错误(死域名、环境归属不一致、对外死值等),即使 diff 内部自洽也必须报——这正是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」)。
|
|
394
400
|
|
|
395
401
|
### 禁止报(不是问题,报了只会拉长循环)
|
|
396
402
|
|
|
@@ -399,6 +405,7 @@
|
|
|
399
405
|
- ❓ **无法确认真假的问题**:读 diff 不能确认会导致 bug 的,默认按「不是问题」处理,禁止报「建议确认一下」这类空泛意见。
|
|
400
406
|
- 🔁 **重复已处理的问题**:已在评论 / 历史中标记 Won't fix 或「已修复于 <hash>」的问题,不再重提。
|
|
401
407
|
- 🔒 **「文档内嵌真实敏感值」**:AGENTS.base.md 明文规定「示例命令 / 文档内嵌真实 Token/密钥」为**有意为之的既定规则**,不属于安全漏洞,**禁止**作为密钥泄露 / 安全漏洞上报,也**禁止**建议删除该条或改回占位符。与「密钥配到 Nacos」「禁止 cat 密钥文件」互不冲突:后者约束运行时配置与终端处理,本条约束文档示例的可复制性,两个场景互不重叠。
|
|
408
|
+
- 🔧 **「AGENTS.base.md 未同步」**:下游业务仓库的 AGENTS.md / CLAUDE.md / .github/copilot-instructions.md 由 **@routerhub/agent-rules 发版同步**(npm 包 postinstall 自动生成),业务仓库内**不存在** `AGENTS.base.md` 源文件,`node merge.js sync` 只在 agent-rules 仓库执行。审稿方**禁止**因「diff 修改了 AGENTS.md / CLAUDE.md 但未出现 AGENTS.base.md 改动」而报错——业务仓库本就没有此文件,规则改动由 agent-rules 发版统一管理,属正常流程。
|
|
402
409
|
|
|
403
410
|
### 判断标准
|
|
404
411
|
|
|
@@ -435,6 +442,16 @@
|
|
|
435
442
|
⚠️ PR 描述有「行为变更表 / 改前改后对比表」时,审查以它为靶心,逐行问「这条真的成立吗」,用 diff 逐行验证;PR 没列表的,review 意见至少覆盖「改了什么行为 / 不变的行为有没有被误伤」两个面。
|
|
436
443
|
⚠️ 对作者标注「不在本 PR 范围」的内容(如相关子系统计费、其它仓库同步):不要求作者修,但必须判断是否构成「已知缺口被静默放过」——已暴露的边界缺口,作者必须在 PR 里显式记录后续动作、或至少点明仍待处理,禁止「提了不等于了」。
|
|
437
444
|
|
|
445
|
+
**④ 外部事实核查(diff 之外的真实性验证)**
|
|
446
|
+
|
|
447
|
+
⚠️ diff 内逻辑审完了,对**部署脚本 / 配置 / 品牌 / 环境归属类改动**,还必须做一轮 diff 之外的「外部事实核查」——这类改动最常见的坑是「看着都对,其实指向了错误的环境」。核查不查 DNS / 不核对归属,纯看 diff 是发现不了的(PR #135 教训:AI review 漏掉的 namespace 归属、死域名、死邮箱,人工 reviewer 靠 `dig` + 读关联 PR 才发现)。按下面的清单逐项验证:
|
|
448
|
+
|
|
449
|
+
- **域名 / 子域名真实存在**:脚本或配置里出现的每个域名、派生子域名,逐个 `dig +short <域名>` 验证有解析记录;约定派生的(如 `doc.<domain>` / `api.<domain>`)不能只看主域成立就推断子域成立——不同子域可能有的解析有的无解析。无解析的死域名必须报(哪怕它只在注释里,也会误导后来者)。
|
|
450
|
+
- **环境归属一致(品牌 ↔ namespace ↔ 项目 ↔ 数据库 ↔ 域名)**:部署脚本里 `PROJECT` / `NACOS_NAMESPACE_ID` / Artifact Registry 仓库 / VPC / 数据库,和脚本服务的品牌、域名是否指向**同一个环境**。最危险的形态:脚本带了正确的域名和品牌,却指向**另一个品牌的库/namespace**——「看着是条正经路径」,实则从没跑过或指向错误环境。发现归属不一致,必须报并给出一致的目标配置。
|
|
451
|
+
- **跨 PR / 跨系统的共享与边界**:改动涉及「多系统共享」的配置(共用项目、共用 namespace、共用仓库)时,读关联 PR 描述、查配置中心真实归属,确认「刻意共用」还是「漏改」——禁止凭 diff 内自洽就默认成立。判定为刻意共用的,必须在脚本/注释里显式写明依据,否则视为漏配。
|
|
452
|
+
- **对外可见的真实值**:写进发布内容 / 对外页面 / 邮件 / 招聘启事等的邮箱、URL、联系方式,必须验证真实存在(邮箱查 MX 记录 `dig MX`、收件箱是否在域名邮件体系内;URL 查 DNS + HTTP 可达)。这类值一旦是死的,比控制台死链更难被发现(用户点了没反应,测试验收也难察觉)。
|
|
453
|
+
- **能查就查,禁止「推断即认可」**:能用一个命令查证的事实(`dig` / `gh pr view` / `curl` / 配置中心),必须查证后再下结论;查不到或需要人工确认的,明确写「待确认」并说明在哪查,禁止凭 diff 内自洽或惯性推断当结论。
|
|
454
|
+
|
|
438
455
|
**与审稿边界的配合**:本章找出的问题,是否上报仍按「必须报 / 禁止报」判断(真问题、真实风险、违反铁律 → 报;口味、防御、无法确认真假 → 不报)。每条意见必须带「新增量全局跟进」的链路证据(具体 diff 行 + 谁赋值谁读谁落库),禁止只给结论不给路径。
|
|
439
456
|
|
|
440
457
|
---
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
所有对 @routerhub/agent-rules 的重大更改都会记录在这个文件中。
|
|
4
4
|
|
|
5
|
+
## [1.5.152] - 2026-08-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- 新增「审查方法 → ④ 外部事实核查」规则(审稿方必做):审查部署脚本 / 配置 / 品牌 / 环境归属类改动时,除 diff 内逻辑审查外,还必须做一轮 diff 之外的真实性核查——域名子域逐个 `dig` 验证(禁止主域成立就推断子域成立)、环境归属一致性(品牌 ↔ namespace ↔ 项目 ↔ 数据库 ↔ 域名是否指向同一环境)、跨 PR / 跨系统共享与边界(读关联 PR 描述、查配置中心真实归属,区分「刻意共用」与「漏改」)、对外可见真实值(邮箱查 MX 记录、URL 查 DNS + HTTP 可达)。原因:这类改动最常见的坑是「看着都对、其实指向了错误的环境」,纯看 diff 无法发现,只有人工靠 `dig` + 读关联 PR 才能暴露(PR #135 教训:AI review 漏掉的 namespace 归属、死域名、死邮箱,人工 reviewer 通过外部核查才发现)。
|
|
10
|
+
- 「审稿边界 → 必须报」清单新增一条:「外部事实核查确认的配置错误」,即使 diff 内部自洽也必须报——这是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」铁律)。
|
|
11
|
+
|
|
5
12
|
## [1.5.124] - 2026-08-12
|
|
6
13
|
|
|
7
14
|
### Added
|
package/package.json
CHANGED
package/rules/frontend.md
CHANGED
|
@@ -7,5 +7,10 @@ outputName: "frontend"
|
|
|
7
7
|
## 前端规则
|
|
8
8
|
|
|
9
9
|
- ⚠️ **默认隐藏滚动条**:页面/容器出现滚动需求时,滚动条默认隐藏(内容仍可正常滚动),不得让滚动条可见。仅当用户明确说「需要滚动条」「可以有滚动条」「显示滚动条」等表述时,才允许显示。
|
|
10
|
+
- ⚠️ **依赖安装统一 `pnpm`**,禁止 `npm install` / `yarn install`(pnpm workspace 混合安装会破坏依赖结构)。
|
|
11
|
+
- ⚠️ **API 请求严格使用 OpenAPI 生成的方法,禁止手写请求或直接拼接路径**;接口变更后先更新 OpenAPI 定义并重新生成 API 代码,再进行业务开发。
|
|
12
|
+
- ⚠️ **用 `console.error` 记录错误**(含函数名/模块名上下文),禁止用 `console.log` 输出错误;提交前移除调试日志和临时代码。
|
|
13
|
+
- ⚠️ **调试过程中产生的中间产物(临时文件、测试脚本、调试截图、dump 文件、临时注释、`console.log` 等)禁止加入 Git 提交**,`.gitignore` 中应配置忽略常见中间产物。
|
|
14
|
+
- TypeScript 类型优先复用 `api` 文件夹下 `typings.d.ts`,不存在时再自定义;组件 `props` 能复用时必须优先复用。
|
|
10
15
|
|
|
11
16
|
<!-- 前端特有规则在此添加 -->
|
package/rules/global.md
CHANGED
|
@@ -269,7 +269,7 @@ name: "通用规则"
|
|
|
269
269
|
## 禁止项
|
|
270
270
|
|
|
271
271
|
- ⚠️ AI 禁止自动执行格式化命令(`npm run format`、`prettier` 等)。
|
|
272
|
-
- ⚠️ 禁止修改 GCLB
|
|
272
|
+
- ⚠️ 禁止修改 GCLB 配置,除用户明确确认外(原因和替代方案见下方「共享基础设施变更铁律」;获得确认后的修改操作须遵循「网关/负载均衡 404 排查规范」的 additive-only 原则)。
|
|
273
273
|
- 禁止修改核心业务文件和 API 相关代码。
|
|
274
274
|
- `.env` 仅允许配端口号,密钥/Token 等敏感信息必须配到 Nacos 配置中心。
|
|
275
275
|
|
package/rules/review-boundary.md
CHANGED
|
@@ -13,6 +13,7 @@ outputName: "review-boundary"
|
|
|
13
13
|
- 🔴 **会导致实际 bug / 数据丢失 / 数据错误 / 安全漏洞**:有明确触发路径(真实输入、真实调用链能复现),读 diff 能确认后果。
|
|
14
14
|
- 🟡 **真实风险**:有明确触发条件,且触发后后果严重(如并发竞态、错误被静默吞掉、未处理空值导致崩溃)。
|
|
15
15
|
- **违反本项目已固化规则**:AGENTS.base.md / AGENTS.private.md 里的 ⚠️ 铁律(DELETE 铁律、部署自包含、数据链路核对、环境配置禁止推断、测试环境验证前置等),diff 中明确违反 → 必须报。
|
|
16
|
+
- **外部事实核查确认的配置错误**:按「审查方法 → ④ 外部事实核查」查出的事实性错误(死域名、环境归属不一致、对外死值等),即使 diff 内部自洽也必须报——这正是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」)。
|
|
16
17
|
|
|
17
18
|
### 禁止报(不是问题,报了只会拉长循环)
|
|
18
19
|
|
|
@@ -21,6 +22,7 @@ outputName: "review-boundary"
|
|
|
21
22
|
- ❓ **无法确认真假的问题**:读 diff 不能确认会导致 bug 的,默认按「不是问题」处理,禁止报「建议确认一下」这类空泛意见。
|
|
22
23
|
- 🔁 **重复已处理的问题**:已在评论 / 历史中标记 Won't fix 或「已修复于 <hash>」的问题,不再重提。
|
|
23
24
|
- 🔒 **「文档内嵌真实敏感值」**:AGENTS.base.md 明文规定「示例命令 / 文档内嵌真实 Token/密钥」为**有意为之的既定规则**,不属于安全漏洞,**禁止**作为密钥泄露 / 安全漏洞上报,也**禁止**建议删除该条或改回占位符。与「密钥配到 Nacos」「禁止 cat 密钥文件」互不冲突:后者约束运行时配置与终端处理,本条约束文档示例的可复制性,两个场景互不重叠。
|
|
25
|
+
- 🔧 **「AGENTS.base.md 未同步」**:下游业务仓库的 AGENTS.md / CLAUDE.md / .github/copilot-instructions.md 由 **@routerhub/agent-rules 发版同步**(npm 包 postinstall 自动生成),业务仓库内**不存在** `AGENTS.base.md` 源文件,`node merge.js sync` 只在 agent-rules 仓库执行。审稿方**禁止**因「diff 修改了 AGENTS.md / CLAUDE.md 但未出现 AGENTS.base.md 改动」而报错——业务仓库本就没有此文件,规则改动由 agent-rules 发版统一管理,属正常流程。
|
|
24
26
|
|
|
25
27
|
### 判断标准
|
|
26
28
|
|
|
@@ -57,6 +59,16 @@ outputName: "review-boundary"
|
|
|
57
59
|
⚠️ PR 描述有「行为变更表 / 改前改后对比表」时,审查以它为靶心,逐行问「这条真的成立吗」,用 diff 逐行验证;PR 没列表的,review 意见至少覆盖「改了什么行为 / 不变的行为有没有被误伤」两个面。
|
|
58
60
|
⚠️ 对作者标注「不在本 PR 范围」的内容(如相关子系统计费、其它仓库同步):不要求作者修,但必须判断是否构成「已知缺口被静默放过」——已暴露的边界缺口,作者必须在 PR 里显式记录后续动作、或至少点明仍待处理,禁止「提了不等于了」。
|
|
59
61
|
|
|
62
|
+
**④ 外部事实核查(diff 之外的真实性验证)**
|
|
63
|
+
|
|
64
|
+
⚠️ diff 内逻辑审完了,对**部署脚本 / 配置 / 品牌 / 环境归属类改动**,还必须做一轮 diff 之外的「外部事实核查」——这类改动最常见的坑是「看着都对,其实指向了错误的环境」。核查不查 DNS / 不核对归属,纯看 diff 是发现不了的(PR #135 教训:AI review 漏掉的 namespace 归属、死域名、死邮箱,人工 reviewer 靠 `dig` + 读关联 PR 才发现)。按下面的清单逐项验证:
|
|
65
|
+
|
|
66
|
+
- **域名 / 子域名真实存在**:脚本或配置里出现的每个域名、派生子域名,逐个 `dig +short <域名>` 验证有解析记录;约定派生的(如 `doc.<domain>` / `api.<domain>`)不能只看主域成立就推断子域成立——不同子域可能有的解析有的无解析。无解析的死域名必须报(哪怕它只在注释里,也会误导后来者)。
|
|
67
|
+
- **环境归属一致(品牌 ↔ namespace ↔ 项目 ↔ 数据库 ↔ 域名)**:部署脚本里 `PROJECT` / `NACOS_NAMESPACE_ID` / Artifact Registry 仓库 / VPC / 数据库,和脚本服务的品牌、域名是否指向**同一个环境**。最危险的形态:脚本带了正确的域名和品牌,却指向**另一个品牌的库/namespace**——「看着是条正经路径」,实则从没跑过或指向错误环境。发现归属不一致,必须报并给出一致的目标配置。
|
|
68
|
+
- **跨 PR / 跨系统的共享与边界**:改动涉及「多系统共享」的配置(共用项目、共用 namespace、共用仓库)时,读关联 PR 描述、查配置中心真实归属,确认「刻意共用」还是「漏改」——禁止凭 diff 内自洽就默认成立。判定为刻意共用的,必须在脚本/注释里显式写明依据,否则视为漏配。
|
|
69
|
+
- **对外可见的真实值**:写进发布内容 / 对外页面 / 邮件 / 招聘启事等的邮箱、URL、联系方式,必须验证真实存在(邮箱查 MX 记录 `dig MX`、收件箱是否在域名邮件体系内;URL 查 DNS + HTTP 可达)。这类值一旦是死的,比控制台死链更难被发现(用户点了没反应,测试验收也难察觉)。
|
|
70
|
+
- **能查就查,禁止「推断即认可」**:能用一个命令查证的事实(`dig` / `gh pr view` / `curl` / 配置中心),必须查证后再下结论;查不到或需要人工确认的,明确写「待确认」并说明在哪查,禁止凭 diff 内自洽或惯性推断当结论。
|
|
71
|
+
|
|
60
72
|
**与审稿边界的配合**:本章找出的问题,是否上报仍按「必须报 / 禁止报」判断(真问题、真实风险、违反铁律 → 报;口味、防御、无法确认真假 → 不报)。每条意见必须带「新增量全局跟进」的链路证据(具体 diff 行 + 谁赋值谁读谁落库),禁止只给结论不给路径。
|
|
61
73
|
|
|
62
74
|
---
|
|
@@ -44,10 +44,60 @@ git push origin "$ORIGINAL_BRANCH"
|
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
# 确认本地功能分支与远程已同步(本地无未推送提交)
|
|
47
|
+
# 先 fetch 让 origin/* 引用全部最新,再用「分支的 upstream..HEAD」精确列举未推送提交
|
|
48
|
+
# ⚠️ 用全量 "git fetch origin" 而不是 "fetch origin <X>":单分支 fetch 只写 FETCH_HEAD/只更新该分支引用,
|
|
49
|
+
# ① 分支已 push 过但本地无远程跟踪引用(如 clone 后手动 push)时,后面 rev-parse --verify "origin/X" 会误判
|
|
50
|
+
# 「远程分支不存在」、把已推送提交误报为未推送;
|
|
51
|
+
# ② upstream 非同名分支(如 push -u origin other/branch)时,@{u} 指向的引用没被单分支 fetch 更新,
|
|
52
|
+
# git log @{u}..HEAD 基于过期引用计算,同样误判。
|
|
53
|
+
# 全量 fetch 一次更新 origin 下所有远程跟踪引用,以上两种场景都消除。
|
|
54
|
+
# ⚠️ 分支可能从未推送过(远程不存在该 ref),git fetch origin 对不存在的引用不会报错中断,
|
|
55
|
+
# 因此无需 || true 兜底;git status 在 fetch 后确认工作区状态
|
|
56
|
+
git fetch origin
|
|
47
57
|
git status
|
|
48
|
-
|
|
58
|
+
# 先选定「比对基准引用」,再分别取两个方向的差集:
|
|
59
|
+
# UNPUSHED = 本地有、基准没有(本地领先,未推送)
|
|
60
|
+
# UNPULLED = 基准有、本地没有(本地落后,未拉取)
|
|
61
|
+
# ⚠️ 基准优先级:origin/功能分支 > @{u} > origin/默认分支。
|
|
62
|
+
# 功能分支通常由 `git checkout -b feature/X origin/main` 创建(upstream=origin/main)、
|
|
63
|
+
# 再用 `git push origin feature/X` 推送(不设 upstream),此时 @{u}=origin/main,
|
|
64
|
+
# 用它做基准会把「功能分支相对 main 的全部新增提交」误报为未推送(前 26 轮就是这么误报的)。
|
|
65
|
+
# 全量 git fetch origin 之后 origin/功能分支 一定是最新,且它就是部署目标(PR 对应分支),
|
|
66
|
+
# 语义最准;@{u} 只在「功能分支从未以自己名字推送过」时作降级。
|
|
67
|
+
DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d: -f2 | tr -d ' ')
|
|
68
|
+
if git rev-parse --verify "origin/$ORIGINAL_BRANCH" >/dev/null 2>&1; then
|
|
69
|
+
BASE_REF="origin/$ORIGINAL_BRANCH"
|
|
70
|
+
elif git rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
|
|
71
|
+
BASE_REF="@{u}"
|
|
72
|
+
elif git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then
|
|
73
|
+
BASE_REF="origin/$DEFAULT_BRANCH"
|
|
74
|
+
else
|
|
75
|
+
# 远程连功能分支与默认分支引用都没有(全新仓库/远程为空)→ 当前分支必然从未推送。
|
|
76
|
+
# ⚠️ 不能用 git log HEAD 兜底:会把 main 全部历史也算进来,输出上千行淹没真正关心的提交;直接阻断并提示。
|
|
77
|
+
echo "❌ 远程找不到 ${ORIGINAL_BRANCH} 与 ${DEFAULT_BRANCH} 分支引用,无法比对推送状态;请先 git push origin ${ORIGINAL_BRANCH} 再部署"
|
|
78
|
+
exit 1
|
|
79
|
+
fi
|
|
80
|
+
UNPUSHED=$(git log "$BASE_REF"..HEAD --oneline)
|
|
81
|
+
UNPULLED=$(git log HEAD.."$BASE_REF" --oneline)
|
|
82
|
+
# ⚠️ 存在未推送提交时必须 exit 1 阻断,不能只打警告继续走部署流程——否则未推送到 PR 分支的代码会被合入/部署到 test,
|
|
83
|
+
# 导致 PR 分支与测试环境代码不一致(违背上文「必须确认所有 review 修复已推送再进行 test 合并」的硬性要求)。
|
|
84
|
+
[ -z "$UNPUSHED" ] && echo "✅ 无未推送提交" || {
|
|
85
|
+
echo "❌ 存在未推送提交:"
|
|
86
|
+
echo "$UNPUSHED"
|
|
87
|
+
exit 1
|
|
88
|
+
}
|
|
89
|
+
# ⚠️ 反向检查本地是否落后于远程:只查 UNPUSHED 会漏掉「本地落后」——远程 PR 分支已有别人/其他环境推的新提交、
|
|
90
|
+
# 而本地还是旧 HEAD 时,git log @{u}..HEAD 为空 → 误报「✅ 无未推送提交」→ 把本地旧代码合进 test 部署,
|
|
91
|
+
# 测试环境跑的不是 PR 最新代码。必须同样阻断,先 git pull 再部署。
|
|
92
|
+
[ -z "$UNPULLED" ] && echo "✅ 本地不落后于远程" || {
|
|
93
|
+
echo "❌ 本地分支落后于远程(远程有本地没有的提交),请先 git pull 再部署:"
|
|
94
|
+
echo "$UNPULLED"
|
|
95
|
+
exit 1
|
|
96
|
+
}
|
|
49
97
|
```
|
|
50
98
|
|
|
99
|
+
⚠️ 比对基准用 `origin/$ORIGINAL_BRANCH`(PR 对应分支的远程引用)而不是 `@{u}` 优先:功能分支通常由 `git checkout -b feature/X origin/main` 创建、再 `git push origin feature/X` 推送(无 `-u`),此时 `@{u}` 仍指向 origin/main,用它做基准会把「功能分支相对 main 的全部新增提交」误报为未推送;而全量 `git fetch origin` 之后 `origin/$ORIGINAL_BRANCH` 就是 PR 分支最新状态,用它对比双向差集(`origin/$ORIGINAL_BRANCH..HEAD` 未推送、`HEAD..origin/$ORIGINAL_BRANCH` 未拉取)语义最准。`@{u}` 仅在功能分支从未以自己名字推送过(origin/$ORIGINAL_BRANCH 引用不存在)时作降级。⚠️ `git log origin/X..HEAD` 对**不存在的引用会直接 fatal**(而不是返回空),因此先用 `git rev-parse --verify "origin/X"` 探测;功能分支、`@{u}` 与默认分支引用都不存在(全新仓库)时无法比对,直接阻断要求先推送。
|
|
100
|
+
|
|
51
101
|
- ⚠️ 若 review 修复还没合进当前功能分支 → 先把修复 commit 到功能分支再推送,禁止带着"修复只在 test 分支"的状态去部署。
|
|
52
102
|
- ⚠️ feature 分支与 test 分支两条线必须保持同步:**所有 review 修复代码必须先落 feature 分支(PR 分支),再合并到 test**,缺一不可。
|
|
53
103
|
|
|
@@ -47,7 +47,7 @@ AI review bot 在**每次 push 到 PR 时自动触发新一轮 review**。因此
|
|
|
47
47
|
```bash
|
|
48
48
|
REPO=$(gh repo view --json nameWithOwner -q '.nameWithOwner')
|
|
49
49
|
PR=$(gh pr view --json number -q '.number')
|
|
50
|
-
HEAD=$(git rev-parse
|
|
50
|
+
HEAD=$(git rev-parse HEAD)
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
记录本轮起始 `HEAD`,用于判断「新 review 是否已针对最新 push」。
|
|
@@ -68,19 +68,16 @@ review body 结构(已实测确认):
|
|
|
68
68
|
- `## 🤖 GPT-5.5 Code Review(交叉验证)`:GPT-5.5 的交叉验证结论
|
|
69
69
|
- 每个模型内部按严重度分节:`## 🔴 Critical` / `## 🟡 Warning` / `## 🔵 Suggestion`
|
|
70
70
|
|
|
71
|
-
**确认「最新一轮 review 是否针对当前 HEAD
|
|
71
|
+
**确认「最新一轮 review 是否针对当前 HEAD」**:调用独立脚本 `check-review.sh` 获取最新 review 的完整 commit_id(脚本处理完整 SHA、按 submitted_at 排序取末条、分页合并),再与当前 HEAD 完整 SHA 比较:
|
|
72
72
|
|
|
73
73
|
```bash
|
|
74
74
|
# 取最新一条对应当前 HEAD 的 review(命中则输出 YES,否则为空)
|
|
75
|
-
CUR=$(git rev-parse
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
[.[] | select(.user.login == "github-actions[bot]") |
|
|
79
|
-
(.commit_id // (try (.body | capture("审查 commit: `(?<h>[0-9a-f]+)`") | .h) catch null))] |
|
|
80
|
-
if .[0] == $cur then "YES" else "NO" end'
|
|
75
|
+
CUR=$(git rev-parse HEAD)
|
|
76
|
+
LATEST=$(bash "$(git rev-parse --show-toplevel)/.claude/skills/loop-review/scripts/check-review.sh" "$REPO" "$PR")
|
|
77
|
+
if [ -n "$LATEST" ] && [ "$LATEST" = "$CUR" ]; then echo "YES"; else echo "NO"; fi
|
|
81
78
|
```
|
|
82
79
|
|
|
83
|
-
|
|
80
|
+
⚠️ `git rev-parse HEAD` 必须用完整 40 位 SHA:`review.commit_id` 是完整 SHA,短 SHA 比较永远不匹配,会误判「review 未到」而空等。
|
|
84
81
|
|
|
85
82
|
### 2. 汇总建议并逐条判断(核心)
|
|
86
83
|
|
|
@@ -150,17 +147,16 @@ git push origin <当前分支>
|
|
|
150
147
|
- push 后 bot 需要一段时间才出新一轮 review(实测几分钟到几十分钟不等),用轮询等待,不要干等:
|
|
151
148
|
|
|
152
149
|
```bash
|
|
153
|
-
CUR=$(git rev-parse
|
|
150
|
+
CUR=$(git rev-parse HEAD)
|
|
154
151
|
for i in $(seq 1 45); do
|
|
155
|
-
R=$(
|
|
156
|
-
|
|
157
|
-
[.[] | select(.user.login == "github-actions[bot]") |
|
|
158
|
-
(.commit_id // (try (.body | capture("审查 commit: `(?<h>[0-9a-f]+)`") | .h) catch null))] | .[0]')
|
|
159
|
-
[ "$R" = "$CUR" ] && echo "NEW_REVIEW_READY" && break
|
|
152
|
+
R=$(bash "$(git rev-parse --show-toplevel)/.claude/skills/loop-review/scripts/check-review.sh" "$REPO" "$PR")
|
|
153
|
+
[ -n "$R" ] && [ "$R" = "$CUR" ] && echo "NEW_REVIEW_READY" && break
|
|
160
154
|
sleep 20
|
|
161
155
|
done
|
|
162
156
|
```
|
|
163
157
|
|
|
158
|
+
⚠️ 等待判断也用完整 SHA + `check-review.sh`(步骤 1 的同一套逻辑),避免 `gh api --jq --arg` 写法失败或短 SHA 误判导致空等 15 分钟。
|
|
159
|
+
|
|
164
160
|
- ⚠️ **遵守心跳约定**:等待期间超过 1 分钟无输出,主动告知「正在等 bot 审查」。
|
|
165
161
|
- 轮询约 15 分钟(45 次 × 20s)仍没出新 review → 停止等待,向用户报告当前状态并询问是否继续等。
|
|
166
162
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# loop-review:查询 PR 上「最新一轮 AI review 审查的 commit_id」。
|
|
3
|
+
# 供 SKILL.md 轮询/判断流程调用,把易错的 jq 转义逻辑收敛到独立脚本,便于测试与维护。
|
|
4
|
+
#
|
|
5
|
+
# 用法:
|
|
6
|
+
# bash check-review.sh <REPO> <PR> # 输出最新 review 审查的 commit_id(完整 SHA),无 review 输出空
|
|
7
|
+
#
|
|
8
|
+
# 依赖:
|
|
9
|
+
# - gh CLI(已登录且有权限读该仓库)
|
|
10
|
+
# - jq >= 1.6:capture(1.5+)、try/catch(1.6+)、-s slurp;本地旧版 jq 会语法报错
|
|
11
|
+
# - 说明:GitHub GET /pulls/{n}/reviews 返回按创建时间升序,但显式 sort_by(.submitted_at) 取最后一条最稳。
|
|
12
|
+
|
|
13
|
+
set -euo pipefail
|
|
14
|
+
|
|
15
|
+
REPO="${1:?用法: bash check-review.sh <REPO> <PR>}"
|
|
16
|
+
PR="${2:?用法: bash check-review.sh <REPO> <PR>}"
|
|
17
|
+
|
|
18
|
+
# $REPO / $PR 直接拼入 URL 路径与 jq filter 字符串,必须先校验格式:
|
|
19
|
+
# - $REPO 形如 owner/name,仅含字母数字、点、下划线、中划线
|
|
20
|
+
# - $PR 必须是纯数字
|
|
21
|
+
# 避免传参含特殊字符时破坏 jq 语法或让请求打到意外路径。
|
|
22
|
+
[[ "$REPO" =~ ^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$ ]] || { echo "错误: REPO 格式非法(应为 owner/repo,收到: ${REPO})" >&2; exit 1; }
|
|
23
|
+
[[ "$PR" =~ ^[0-9]+$ ]] || { echo "错误: PR 必须是纯数字(收到: ${PR})" >&2; exit 1; }
|
|
24
|
+
|
|
25
|
+
# jq 版本校验:下方 filter 用了 try/catch(jq 1.6+ 语法),Ubuntu 20.04 默认 jq 1.5 会直接语法报错,
|
|
26
|
+
# 且报错信息晦涩难定位。启动时主动检测版本,给明确提示而不是等 jq 报错。
|
|
27
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
28
|
+
echo "错误: 未找到 jq(脚本依赖 jq >= 1.6,请先安装)" >&2
|
|
29
|
+
exit 1
|
|
30
|
+
fi
|
|
31
|
+
# jq --version 保证单行输出(如 jq-1.6),grep -oE 最多匹配一行,head -1 冗余,直接省略
|
|
32
|
+
JQ_VERSION="$(jq --version 2>/dev/null | grep -oE '[0-9]+(\.[0-9]+)+')"
|
|
33
|
+
if [ -z "$JQ_VERSION" ]; then
|
|
34
|
+
echo "错误: 无法从 jq --version 解析出版本号(输出: $(jq --version 2>&1)),脚本依赖 jq >= 1.6" >&2
|
|
35
|
+
exit 1
|
|
36
|
+
fi
|
|
37
|
+
# 版本比较:拆分主/次版本号后直接做整数比较(review 建议,替代反直觉的 awk exit 语义)。
|
|
38
|
+
# 直白逻辑:主版本 >1,或主版本==1 且次版本>=6 → 满足要求,不进入报错分支。
|
|
39
|
+
JQ_MAJOR="${JQ_VERSION%%.*}"
|
|
40
|
+
JQ_MINOR_REMAINDER="${JQ_VERSION#*.}"
|
|
41
|
+
JQ_MINOR="${JQ_MINOR_REMAINDER%%.*}"
|
|
42
|
+
if [ "$JQ_MAJOR" -lt 1 ] || { [ "$JQ_MAJOR" -eq 1 ] && [ "$JQ_MINOR" -lt 6 ]; }; then
|
|
43
|
+
echo "错误: jq 版本过低(当前 ${JQ_VERSION},需要 >= 1.6):try/catch 语法不可用" >&2
|
|
44
|
+
exit 1
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
# ⚠️ 分页处理:gh api --paginate 不带 --jq,把每页原始 JSON 输出(多页 = 多个数组流),
|
|
48
|
+
# 再管道给 jq -s(slurp)合并成单一数组后统一筛选排序。
|
|
49
|
+
# --paginate --jq 会「每页独立执行 jq」,review 超过一页时输出多行 commit_id,调用方误判。
|
|
50
|
+
# ⚠️ jq filter 用单引号包裹(无 shell 变量插值),内部反引号/双引号无需 shell 转义,避免转义脆弱性。
|
|
51
|
+
# `-r` raw 输出:去掉 JSON 字符串引号,调用方 `[ "$LATEST" = "$CUR" ]` 才能比较成功。
|
|
52
|
+
# `(add // [])`:gh api 完全无输出(如 PR 不存在/无任何 review)时,slurp 的输入为空流,
|
|
53
|
+
# add 得 null,后续 select 会报「Cannot iterate over null」;// [] 让空输入变空数组。
|
|
54
|
+
# `select($cid | type == "string" and length == 40)`:只保留完整 40 位 SHA 的 review。
|
|
55
|
+
# 优先用 commit_id 字段(GitHub API 保证完整),fallback 解析 body「审查 commit」时可能拿到短 SHA,
|
|
56
|
+
# 短 SHA 与调用方 git rev-parse HEAD(完整 40 位)比较永远不匹配 → 空等;这里过滤掉非 40 位值,
|
|
57
|
+
# 宁可判定「未审查」也不误判「已审查」。
|
|
58
|
+
gh api "repos/$REPO/pulls/$PR/reviews?per_page=100" --paginate | jq -rs '
|
|
59
|
+
(add // []) | [.[] | select(.user.login == "github-actions[bot]" and .submitted_at != null) |
|
|
60
|
+
(.commit_id // (try (.body | capture("审查 commit: `(?<h>[0-9a-f]+)`") | .h) catch null)) as $cid |
|
|
61
|
+
select($cid | type == "string" and length == 40) |
|
|
62
|
+
{commit_id: $cid, submitted_at}] |
|
|
63
|
+
sort_by(.submitted_at) | .[-1].commit_id // empty
|
|
64
|
+
'
|
|
@@ -23,7 +23,7 @@ description: >-
|
|
|
23
23
|
1. ✅ 循环 review 走完(`/loop-review`,直到某一轮不再冒出值得修的新问题)
|
|
24
24
|
2. ✅ 最新代码已提交并推送到 PR 分支
|
|
25
25
|
3. ✅ 已通过 `/deploy-test` 重新部署到测试环境(部署的是 review 之后的最终代码)
|
|
26
|
-
4. ✅ 测试环境用真实数据 +
|
|
26
|
+
4. ✅ 测试环境用真实数据 + 真实交互复测通过(每张截图浏览器真实视口全页、箭头标注、URL 可见)
|
|
27
27
|
5. ✅ 与主分支无冲突(`gh pr view <PR> --json mergeable -q .mergeable` = `MERGEABLE`)
|
|
28
28
|
6. ✅ GitHub 静态编译通过(`gh pr checks <PR>` 无 `failure`)
|
|
29
29
|
7. ✅ PR 链接已发送给用户
|
|
@@ -35,17 +35,35 @@ description: >-
|
|
|
35
35
|
### 1. 确认循环 review 已完成
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
gh
|
|
38
|
+
REPO=$(gh repo view --json nameWithOwner -q '.nameWithOwner')
|
|
39
|
+
PR=$(gh pr view --json number -q '.number')
|
|
40
|
+
CUR=$(git rev-parse HEAD)
|
|
41
|
+
LATEST=$(bash "$(git rev-parse --show-toplevel)/.claude/skills/loop-review/scripts/check-review.sh" "$REPO" "$PR")
|
|
42
|
+
echo "最新 review 审查 commit: $LATEST"
|
|
43
|
+
echo "当前 HEAD: $CUR"
|
|
39
44
|
```
|
|
40
45
|
|
|
41
|
-
|
|
46
|
+
`check-review.sh` 返回的 LATEST 只回答「最新一轮 AI review **审查了哪个 commit**」,它只证明「bot 已到达当前 HEAD」,**不**证明「该轮 review 里没有值得修的问题」。因此「LATEST = CUR」只是**必要条件**,不能单独作为「循环 review 已收敛」的判据。
|
|
47
|
+
|
|
48
|
+
⚠️ 收敛判据必须是两条**同时满足**:
|
|
49
|
+
1. **review 已到达**:`LATEST` 等于 `CUR`(或等于 /loop-review 最后处理过的 commit),否则 bot 还没审到最新 push,循环 review 尚未完成;
|
|
50
|
+
2. **该轮已无值得修的新问题**:打开最新一条 review 的正文(`gh pr view $PR --json reviews -q '.reviews[-1].body'`,或 `/loop-review` 的抓取命令),逐条核对——所有值得修的问题都已修复,剩余问题均已有明确的 Won't fix / 误报 / 已处理回复。只要还有一条值得修而未处理的问题,就必须回到 `/loop-review` 修复后重新推送,**禁止**把它带进发布收尾闭环。
|
|
51
|
+
|
|
52
|
+
⚠️ 不要用 `gh pr view --json comments -q '.comments | length'`(评论总数)判断闭环——评论数与「循环 review 是否收敛」无对应关系,验证不了。判断依据是「最新 review 是否针对当前 HEAD + 该轮是否已无值得修的新问题」,即 `/loop-review` 步骤 1 的同一套逻辑。若循环 review 还没走完,**先补跑 `/loop-review`,不得跳过**。
|
|
42
53
|
|
|
43
54
|
### 2. 确保最新代码已推送
|
|
44
55
|
|
|
45
56
|
循环 review 期间若有改动,确保已提交并推送到 PR 分支。若 review 过程中没有新增 commit,且已满足「无值得修的新问题」,则跳过本步(不必为了触发而空 push)。
|
|
46
57
|
|
|
47
58
|
```bash
|
|
48
|
-
git status
|
|
59
|
+
# ⚠️ git status 即使存在未提交改动也返回 0,直接 git status && git push 会把「还有未提交改动」误判成「已推送」。
|
|
60
|
+
# 必须用 --porcelain 显式检查工作区/暂存区是否干净,脏则中止推送。
|
|
61
|
+
if [ -n "$(git status --porcelain)" ]; then
|
|
62
|
+
echo "❌ 工作区存在未提交改动,请先 commit 后再 push"
|
|
63
|
+
git status
|
|
64
|
+
exit 1
|
|
65
|
+
fi
|
|
66
|
+
git push origin "$(git branch --show-current)"
|
|
49
67
|
```
|
|
50
68
|
|
|
51
69
|
### 3. 重新部署到测试环境(最容易漏的一步)
|
|
@@ -57,7 +75,7 @@ git status && git push origin "$(git branch --show-current)"
|
|
|
57
75
|
### 4. 测试环境复测 + 全程截图标注
|
|
58
76
|
|
|
59
77
|
- 在测试环境用**真实数据 + 真实交互**测试本次改动(遵循「⚠️ 页面功能验证铁律」「⚠️ 用户视角测试铁律」)。
|
|
60
|
-
- 每步截图保留(遵循「⚠️
|
|
78
|
+
- 每步截图保留(遵循「⚠️ 截图规范」:浏览器真实视口宽(需要更清晰时提高 DPR)、`fullPage` 全页、URL 可见、存盘到 `docs/` 对应子目录),并用**箭头标注**关键改动区域/验证点,让看的人一眼看懂这张图证明了什么。
|
|
61
79
|
- 若本次改动是纯后端/基础设施,按「非 UI / 后端 / 基础设施改动的效果截图获取方法」把 curl 响应渲染成暗色终端 HTML 截图。
|
|
62
80
|
|
|
63
81
|
### 5. 确认没问题才算完成
|
|
@@ -91,4 +109,4 @@ PR 合并后按项目发布流程执行根目录 `./release.sh` 发新版本(
|
|
|
91
109
|
- 循环 review 细节:`loop-review` skill
|
|
92
110
|
- 部署测试环境:`deploy-test` skill
|
|
93
111
|
- 创建 PR / 截图上传:`create-pr` skill
|
|
94
|
-
- 验证证据产出:`visual-report` skill
|
|
112
|
+
- 验证证据产出:`visual-report` skill
|
|
@@ -27,20 +27,27 @@ description: >-
|
|
|
27
27
|
```
|
|
28
28
|
元素在「整页」中的 CSS 位置 = rect.left + scrollX
|
|
29
29
|
rect.top + scrollY
|
|
30
|
-
截图里对应像素位置 = 该 CSS 位置(fullPage 拼接时 1 CSS px = 1 设备像素) x 截图宽度 / 整页宽
|
|
31
30
|
```
|
|
32
31
|
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
fullPage 截图宽高 = 整页 CSS 宽高 × `devicePixelRatio`(DPR)。因此:
|
|
33
|
+
|
|
34
|
+
- **DPR = 1**:整页 CSS 坐标 = 截图像素坐标,直接给 annotate.js 传 `x = rect.left + scrollX`、`y = rect.top + scrollY`,无需任何换算参数。
|
|
35
|
+
- **DPR ≠ 1**(`set viewport <W> <H> 2` 提高了 DPR):必须传 `--fullpage --dpr N`(N = `window.devicePixelRatio`),annotate.js 按 DPR 等比缩放,`w/h` 一并缩放。
|
|
36
|
+
|
|
37
|
+
⚠️ **禁止**在 fullPage 场景用 `--viewport "cssW,cssH"` 让 annotate.js 按 `imageH / cssH` 算 Y 轴——`cssH` 是视口高不是整页高,页面越长标注越往下偏。
|
|
38
|
+
|
|
39
|
+
⚠️ **坐标捕获命令会返回 `dpr`**:截图时记下 `set viewport` 是否用了 DPR>1,若用了必须把捕获到的 `dpr` 一并传给 annotate.js(`--fullpage --dpr <dpr>`),不要依赖记忆。
|
|
35
40
|
|
|
36
41
|
### 情形 B:设置了自定义 viewport 视口的截图(截图宽度 = viewport 宽)
|
|
37
42
|
|
|
38
|
-
浏览器把 viewport 设成 X×Y,截图宽高 = X×Y(`window.innerWidth` / `innerHeight
|
|
43
|
+
浏览器把 viewport 设成 X×Y,截图宽高 = X×Y × DPR(`window.innerWidth` / `innerHeight` 是 CSS 宽高)。元素坐标直接用 `rect` 的值即可,但传给 annotate.js 时:
|
|
44
|
+
- DPR = 1:直接传,无需参数;
|
|
45
|
+
- DPR > 1:传 `--viewport "cssW,cssH"`(cssW/cssH = `window.innerWidth/Height`),annotate.js 按截图/视口换算。
|
|
39
46
|
|
|
40
47
|
### 情形 C:一个普通(非拼接、无缩放)截图
|
|
41
48
|
|
|
42
49
|
如果截图就是某视口的渲染结果,且截图宽 ≠ CSS 视口宽,说明有 `devicePixelRatio` 缩放。
|
|
43
|
-
此时标注坐标 = CSS 坐标 × (截图宽 / 视口 CSS 宽)(横向),(截图高 / 视口 CSS 高)
|
|
50
|
+
此时标注坐标 = CSS 坐标 × (截图宽 / 视口 CSS 宽)(横向),(截图高 / 视口 CSS 高)(纵向)——等价于传 `--viewport "cssW,cssH"` 给 annotate.js 自动换算。
|
|
44
51
|
|
|
45
52
|
## 坐标捕获命令(在打开的浏览器标签页上执行)
|
|
46
53
|
|
|
@@ -69,17 +76,19 @@ description: >-
|
|
|
69
76
|
|
|
70
77
|
标注由 `annotate.js`(本 skill 目录内的零依赖 Node 脚本)完成。它把截图 + 坐标转成自包含标注 HTML(截图 base64 内嵌、SVG 箭头/框/文字精确放在换算后的坐标上),再用无头浏览器渲染为 PNG。
|
|
71
78
|
|
|
72
|
-
脚本与 SKILL.md 位于同一 skill 目录(随 agent-rules 同步到各项目 `.claude/skills/screenshot-annotate
|
|
79
|
+
脚本与 SKILL.md 位于同一 skill 目录(随 agent-rules 同步到各项目 `.claude/skills/screenshot-annotate/`)。调用时定位到该目录(在项目根目录下直接粘贴即可执行,禁止用 `BASH_SOURCE[0]`——那只在脚本文件内有效,粘贴到终端时为空):
|
|
73
80
|
|
|
74
81
|
```bash
|
|
75
|
-
|
|
82
|
+
# 从项目根目录定位 skill 目录(git rev-parse --show-toplevel 返回仓库根,与 loop-review 里 check-review.sh 的取法一致)
|
|
83
|
+
ROOT="$(git rev-parse --show-toplevel)"
|
|
84
|
+
SKILL_DIR="$ROOT/.claude/skills/screenshot-annotate"
|
|
76
85
|
node "$SKILL_DIR/annotate.js" \
|
|
77
86
|
shot.png shot-annot.html \
|
|
78
87
|
--json '[{"x":412,"y":1240,"w":160,"h":48,"text":"新增的保存按钮","color":"#22c55e"}]' \
|
|
79
88
|
--label "✅ 修复后" --labelColor "#f43f5e" --url "https://api-test.xxx/gateway/dashboard"
|
|
80
89
|
```
|
|
81
90
|
|
|
82
|
-
- 坐标默认视为**设备像素**(截图内坐标);`--viewport "cssW,cssH"` 传入时视为 CSS
|
|
91
|
+
- 坐标默认视为**设备像素**(截图内坐标);`--viewport "cssW,cssH"` 传入时视为 CSS 视口坐标自动换算(非 fullPage);`--fullpage --dpr N` 用于 fullPage 全页截图(坐标视为整页 CSS 坐标,按 DPR 缩放)
|
|
83
92
|
- `--label` 顶部标题条(适合「修复前/修复后」对比)、`--url` 在标题条显示 URL 满足「URL 可见」
|
|
84
93
|
- `text` 文本框自动排布在目标旁空闲侧,不遮挡内容;`w/h` 存在时画高亮圆角框指向其中心
|
|
85
94
|
|
|
@@ -17,16 +17,19 @@
|
|
|
17
17
|
* - 默认:--json 里的 x/y/w/h 视为「截图设备像素坐标」(截图像素)。
|
|
18
18
|
* 画布里实际位置 = margin + 给定值。
|
|
19
19
|
* - 传 --viewport "cssW,cssH":x/y/w/h 视为「CSS 视口坐标」,
|
|
20
|
-
*
|
|
20
|
+
* 脚本按 (截图宽/cssW)、(截图高/cssH) 换算成设备像素。仅适用于非 fullPage 视口截图。
|
|
21
|
+
* - 传 --fullpage --dpr N:x/y/w/h 视为「整页 CSS 坐标」,按 DPR 等比缩放
|
|
22
|
+
* (fullPage 全页截图宽高 = CSS 宽高 × DPR)。⚠️ 禁止用 imageH/viewport.h 算 Y 轴,
|
|
23
|
+
* viewport.h 是视口高不是整页高,会放错。
|
|
21
24
|
* - 全页拼接截图时,元素相对整页的左/顶 = rect.left + scrollX / rect.top + scrollY,
|
|
22
|
-
* 把这两个和作为 x/y
|
|
25
|
+
* 把这两个和作为 x/y 传入(这就是整页里的 CSS 位置,配 --fullpage --dpr 使用)。
|
|
23
26
|
*
|
|
24
27
|
* 用法:
|
|
25
28
|
* node annotate.js <image> <outHtml> \
|
|
26
29
|
* --json '[{"x":412,"y":1240,"w":160,"h":48,"text":"新增的保存按钮","color":"#22c55e"}]' \
|
|
27
30
|
* --label "✅ 修复后" [--labelColor "#f43f5e"] [--viewport "1440,900"] [--margin-frac 0.05]
|
|
28
31
|
*
|
|
29
|
-
* <image> 输入截图(PNG/JPG/WebP
|
|
32
|
+
* <image> 输入截图(PNG/JPG/WebP/GIF,尺寸由 IHDR / JPEG SOF / WebP RIFF / GIF 头探测,sips 仅作兜底)
|
|
30
33
|
* <outHtml> 输出的标注 HTML 路径
|
|
31
34
|
* --json 标注数组 JSON,每项:{x,y[,w,h][,text][,color][,stroke-width]}
|
|
32
35
|
* - x/y:目标点(默认设备像素坐标;传 --viewport 时为 CSS 坐标)
|
|
@@ -35,8 +38,9 @@
|
|
|
35
38
|
* - color:主色(箭头/框/文本描边),缺省 #f43f5e
|
|
36
39
|
* --label 顶部标题条文字(适合「修复前/修复后」对比图),可省略
|
|
37
40
|
* --labelColor 标题条主题色,缺省 #f43f5e
|
|
38
|
-
* --viewport "cssW,cssH"
|
|
39
|
-
* --
|
|
41
|
+
* --viewport "cssW,cssH" 非 fullPage 视口截图:坐标视为 CSS 视口坐标,按截图/视口换算
|
|
42
|
+
* --fullpage --dpr N fullPage 全页截图:坐标视为整页 CSS 坐标,按 DPR 等比缩放
|
|
43
|
+
* --margin-frac 画布四周留白比例(相对图片**短边**,另设绝对上限 200px,防全页长图画布过大),缺省 0.05
|
|
40
44
|
* --url 可选:在标题条右侧显示页面 URL(用于「URL 可见」证据要求)
|
|
41
45
|
*
|
|
42
46
|
* 渲染为 PNG 的固定流程(配合 screenshot-annotate skill):
|
|
@@ -105,19 +109,30 @@ function jpegSize(buf) {
|
|
|
105
109
|
continue;
|
|
106
110
|
}
|
|
107
111
|
const marker = buf[pos + 1];
|
|
108
|
-
//
|
|
112
|
+
// JPEG 规范允许 marker 前缀 0xFF 与 marker 字节之间出现任意多个 0xFF 填充字节。
|
|
113
|
+
// 遇到 FF FF D8 这类序列时 marker === 0xff,若不跳过会把 marker 当段长读、pos 跳到随机偏移,
|
|
114
|
+
// 甚至可能在错误偏移上命中 0xC0..0xCF 返回假宽高,导致画布/标注坐标整体错位。这里 pos++ 继续,把第二个 FF 当新前缀。
|
|
115
|
+
if (marker === 0xff) {
|
|
116
|
+
pos++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// 无长度字段的标记段:SOI(FFD8)/EOI(FFD9)/TEM(FF01)/RSTn(FFD0-D7) 等,跳过 2 字节即可。
|
|
120
|
+
// 若不跳过会把后续两字节误当段长,pos 跳到错误位置,甚至解析出错误的宽高。
|
|
121
|
+
if (
|
|
122
|
+
marker === 0x01 ||
|
|
123
|
+
(marker >= 0xd0 && marker <= 0xd9)
|
|
124
|
+
) {
|
|
125
|
+
pos += 2;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
// SOF0..15(除 DHT C4、JPG C8、DAC CC);其余带长度的 APPn/COM 等靠段长跳过
|
|
109
129
|
if (
|
|
110
130
|
marker >= 0xc0 &&
|
|
111
131
|
marker <= 0xcf &&
|
|
112
132
|
marker !== 0xc4 &&
|
|
113
133
|
marker !== 0xc8 &&
|
|
114
|
-
marker !== 0xcc
|
|
115
|
-
(marker < 0xd0 || marker > 0xd7) &&
|
|
116
|
-
marker !== 0xda &&
|
|
117
|
-
marker !== 0xdb &&
|
|
118
|
-
marker !== 0xdc
|
|
134
|
+
marker !== 0xcc
|
|
119
135
|
) {
|
|
120
|
-
const segLen = buf.readUInt16BE(pos + 2);
|
|
121
136
|
const height = buf.readUInt16BE(pos + 5);
|
|
122
137
|
const width = buf.readUInt16BE(pos + 7);
|
|
123
138
|
return { width, height };
|
|
@@ -145,8 +160,84 @@ function sipsSize(filePath) {
|
|
|
145
160
|
return null;
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
function webpSize(buf) {
|
|
164
|
+
// RIFF....WEBPVP8 / VP8L / VP8X
|
|
165
|
+
if (
|
|
166
|
+
buf.length >= 30 &&
|
|
167
|
+
buf.toString("ascii", 0, 4) === "RIFF" &&
|
|
168
|
+
buf.toString("ascii", 8, 12) === "WEBP"
|
|
169
|
+
) {
|
|
170
|
+
const fourcc = buf.toString("ascii", 12, 16);
|
|
171
|
+
if (fourcc === "VP8X") {
|
|
172
|
+
// VP8X:24-bit 宽/高,各减 1,最小单位 1px
|
|
173
|
+
const w = 1 + buf.readUIntLE(24, 3);
|
|
174
|
+
const h = 1 + buf.readUIntLE(27, 3);
|
|
175
|
+
return { width: w, height: h };
|
|
176
|
+
}
|
|
177
|
+
if (fourcc === "VP8L") {
|
|
178
|
+
// VP8L:14-bit 宽 + 14-bit 高(各减 1)
|
|
179
|
+
const b = buf;
|
|
180
|
+
const bits = b.readUInt32LE(21);
|
|
181
|
+
const w = (bits & 0x3fff) + 1;
|
|
182
|
+
const h = ((bits >> 14) & 0x3fff) + 1;
|
|
183
|
+
return { width: w, height: h };
|
|
184
|
+
}
|
|
185
|
+
if (fourcc === "VP8 ") {
|
|
186
|
+
// VP8 lossy:宽高字段低 14 位是实际尺寸,高 2 位是 scale 位(水平和垂直缩放 2-bit),
|
|
187
|
+
// 直接读 16 位会把 scale 位混入宽高,导致 >16383px 的图尺寸算错。必须 & 0x3fff 屏蔽高 2 位。
|
|
188
|
+
const w = buf.readUInt16LE(26) & 0x3fff;
|
|
189
|
+
const h = buf.readUInt16LE(28) & 0x3fff;
|
|
190
|
+
if (w > 0 && h > 0) return { width: w, height: h };
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function gifSize(buf) {
|
|
197
|
+
// GIF8[79]a + 16-bit LE 宽高
|
|
198
|
+
const isGif =
|
|
199
|
+
(buf.length >= 10 && buf.toString("ascii", 0, 6) === "GIF87a") ||
|
|
200
|
+
(buf.length >= 10 && buf.toString("ascii", 0, 6) === "GIF89a");
|
|
201
|
+
if (isGif) {
|
|
202
|
+
const w = buf.readUInt16LE(6);
|
|
203
|
+
const h = buf.readUInt16LE(8);
|
|
204
|
+
if (w > 0 && h > 0) return { width: w, height: h };
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
148
209
|
function detectSize(filePath, buf) {
|
|
149
|
-
return
|
|
210
|
+
return (
|
|
211
|
+
pngSize(buf) ||
|
|
212
|
+
jpegSize(buf) ||
|
|
213
|
+
webpSize(buf) ||
|
|
214
|
+
gifSize(buf) ||
|
|
215
|
+
sipsSize(filePath)
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 按文件内容(magic bytes)判断 MIME,避免依赖扩展名——扩展名可能与实际内容不符
|
|
220
|
+
// (如 JPEG 内容误命名 .png),此时若按扩展名生成 data:image/png;base64 会渲染异常。
|
|
221
|
+
// 各魔数探测函数已按内容识别格式,直接复用它返回的类型;找不到魔数才回退扩展名(sips 兜底场景)。
|
|
222
|
+
function detectMime(buf, filePath) {
|
|
223
|
+
if (buf.length >= 8 && buf.readUInt32BE(0) === 0x89504e47) return "image/png";
|
|
224
|
+
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "image/jpeg";
|
|
225
|
+
if (
|
|
226
|
+
buf.length >= 12 &&
|
|
227
|
+
buf.toString("ascii", 0, 4) === "RIFF" &&
|
|
228
|
+
buf.toString("ascii", 8, 12) === "WEBP"
|
|
229
|
+
) {
|
|
230
|
+
return "image/webp";
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
buf.length >= 6 &&
|
|
234
|
+
(buf.toString("ascii", 0, 6) === "GIF87a" || buf.toString("ascii", 0, 6) === "GIF89a")
|
|
235
|
+
) {
|
|
236
|
+
return "image/gif";
|
|
237
|
+
}
|
|
238
|
+
if (buf.length >= 4 && buf.toString("ascii", 0, 4) === "<svg") return "image/svg+xml";
|
|
239
|
+
if (buf.length >= 2 && buf[0] === 0x42 && buf[1] === 0x4d) return "image/bmp";
|
|
240
|
+
return extMime(filePath);
|
|
150
241
|
}
|
|
151
242
|
|
|
152
243
|
// ---------------------------------------------------------------- HTML 安全
|
|
@@ -173,13 +264,21 @@ function extMime(filePath) {
|
|
|
173
264
|
}
|
|
174
265
|
|
|
175
266
|
// ---------------------------------------------------------------- 布局工具(生成 SVG 常量)
|
|
176
|
-
//
|
|
267
|
+
// 文字排版:按字符近似估算宽度(汉字/全角≈1.0em,拉丁≈0.55em,常用符号/emoji≈全宽)
|
|
177
268
|
function estimateTextMetrics(text, fontSize) {
|
|
178
269
|
const chars = [...text];
|
|
179
270
|
let w = 0;
|
|
180
271
|
for (const ch of chars) {
|
|
181
272
|
const code = ch.codePointAt(0);
|
|
182
|
-
|
|
273
|
+
// 全宽判定:汉字/全角(≥0x2E80)按 1em;
|
|
274
|
+
// 常用符号/emoji 实际渲染接近全宽,也必须按 1em 估——按半宽会撑破文本框被 overflow:hidden 裁掉
|
|
275
|
+
// (SKILL.md 示例 --label "✅ 修复后" 就含这类字符):
|
|
276
|
+
// - 箭头/数学/杂项符号等 0x2190-0x2BFF(含 ✅ U+2705、⚠ U+26A0、→ U+2192)
|
|
277
|
+
// - 0x1F300+ 各 Emoji 区段
|
|
278
|
+
// 拉丁/数字/标点(<0x2190)才按半宽 0.55em。
|
|
279
|
+
const fullWidth =
|
|
280
|
+
code >= 0x2e80 || (code >= 0x2190 && code <= 0x2bff) || code >= 0x1f300;
|
|
281
|
+
const width = fullWidth ? fontSize : fontSize * 0.55;
|
|
183
282
|
w += width;
|
|
184
283
|
}
|
|
185
284
|
return { width: Math.ceil(w) + 2 * fontSize, height: Math.ceil(fontSize * 1.7) };
|
|
@@ -197,20 +296,25 @@ function buildHtml({
|
|
|
197
296
|
url,
|
|
198
297
|
marginFrac,
|
|
199
298
|
}) {
|
|
200
|
-
|
|
299
|
+
// margin 基于图片短边并设绝对上限:全页长图高度可达上万像素,
|
|
300
|
+
// 若按长边 5% 会让画布膨胀到两万多高,Chrome 截图直接失败。
|
|
301
|
+
const margin = Math.max(24, Math.min(200, Math.round(Math.min(imageW, imageH) * marginFrac)));
|
|
201
302
|
const W = imageW + margin * 2;
|
|
202
303
|
const H = imageH + margin * 2;
|
|
203
304
|
const imgData = fs.readFileSync(imagePath).toString("base64");
|
|
204
305
|
const dataUri = `data:${imageMime};base64,${imgData}`;
|
|
205
306
|
|
|
206
307
|
// 预处理标注:换算坐标(CSS→设备像素若给 viewport)、排版文本
|
|
308
|
+
// ⚠️ 底图实际渲染在 (margin, margin),因此所有标注坐标统一加 margin,
|
|
309
|
+
// 后续几何计算与边界判断全部在画布坐标系进行,避免整体偏移 margin。
|
|
207
310
|
const placed = [];
|
|
208
311
|
for (const a of annotations) {
|
|
209
|
-
|
|
312
|
+
// a.color 与 labelColor 走同一套 6 位十六进制校验,防止非法值/注入拼进 SVG
|
|
313
|
+
const color = /^#[0-9a-fA-F]{6}$/.test(a.color) ? a.color : "#f43f5e";
|
|
210
314
|
const lw = num(a["stroke-width"], Math.max(3, Math.round(W / 900)));
|
|
211
315
|
// 换算已在 main() 中完成(CSS 坐标 → 设备像素)
|
|
212
|
-
const x = num(a.x, 0);
|
|
213
|
-
const y = num(a.y, 0);
|
|
316
|
+
const x = margin + num(a.x, 0);
|
|
317
|
+
const y = margin + num(a.y, 0);
|
|
214
318
|
const w = num(a.w, 0);
|
|
215
319
|
const h = num(a.h, 0);
|
|
216
320
|
placed.push({ ...a, x, y, w, h, color, lw });
|
|
@@ -222,18 +326,19 @@ function buildHtml({
|
|
|
222
326
|
// 生成每条标注的 SVG 片段
|
|
223
327
|
const parts = [];
|
|
224
328
|
for (const a of placed) {
|
|
225
|
-
//
|
|
329
|
+
// 目标中心(已在画布坐标系,含 margin)
|
|
226
330
|
const tx = a.x + a.w / 2;
|
|
227
331
|
const ty = a.y + a.h / 2;
|
|
228
|
-
const
|
|
229
|
-
const metrics = a.text ? estimateTextMetrics(a.text,
|
|
230
|
-
const boxW = Math.ceil((metrics.width || 0) +
|
|
231
|
-
const boxH = Math.ceil(metrics.height +
|
|
332
|
+
const fontSize = Math.max(16, Math.round(imageW / 90));
|
|
333
|
+
const metrics = a.text ? estimateTextMetrics(a.text, fontSize) : { width: 0, height: 0 };
|
|
334
|
+
const boxW = Math.ceil((metrics.width || 0) + fontSize * 1.2);
|
|
335
|
+
const boxH = Math.ceil(metrics.height + fontSize * 0.6);
|
|
232
336
|
const pad = 8;
|
|
233
337
|
const bw = a.text ? boxW : 0;
|
|
234
338
|
const bh = a.text ? boxH : 0;
|
|
235
339
|
|
|
236
340
|
// 文本候选方位(上、下、左、右),取第一个不越界且不遮目标的
|
|
341
|
+
// 坐标已含 margin,图片区在 [margin, margin+imageW] × [margin, margin+imageH]
|
|
237
342
|
const cands = [
|
|
238
343
|
{ bx: tx - bw / 2, by: ty - gap - bh, name: "up" },
|
|
239
344
|
{ bx: tx - bw / 2, by: ty + gap, name: "down" },
|
|
@@ -253,12 +358,23 @@ function buildHtml({
|
|
|
253
358
|
break;
|
|
254
359
|
}
|
|
255
360
|
}
|
|
256
|
-
|
|
361
|
+
// 目标靠近页面顶部/底部且文本较宽时,四个候选可能全部越界。此时不能直接丢出越界的 cands[0]
|
|
362
|
+
// (body overflow:hidden + 固定画布会把越界部分裁掉,文本框和箭头一起看不见——目标在首屏是最常见场景)。
|
|
363
|
+
// 兜底:取 up 候选并把 bx/by clamp 进【画布】(W×H,允许压到 margin 留白区,但绝不越出画布)。
|
|
364
|
+
// ⚠️ clamp 上界用画布尺寸 W/H 而非图片区(margin+imageW):文本框比图片还宽时,
|
|
365
|
+
// 用图片区上界 margin+imageW-bw 会算出负值,clamp 后仍越界;用 W-bw 保证至少放进画布。
|
|
366
|
+
// bw 比画布还宽的极端情况(超长文本),上界取 0,框左缘贴画布左缘,文字溢出由画布裁,但框和箭头至少可见。
|
|
367
|
+
if (!slot) {
|
|
368
|
+
const bx0 = cands[0].bx, by0 = cands[0].by;
|
|
369
|
+
const bxMax = Math.max(0, W - bw);
|
|
370
|
+
const byMax = Math.max(0, H - bh);
|
|
371
|
+
slot = { ...cands[0], bx: Math.min(Math.max(0, bx0), bxMax), by: Math.min(Math.max(0, by0), byMax) };
|
|
372
|
+
}
|
|
257
373
|
|
|
258
374
|
const hasTarget = a.w > 0 && a.h > 0;
|
|
259
375
|
const hasText = Boolean(a.text);
|
|
260
376
|
|
|
261
|
-
//
|
|
377
|
+
// 高亮目标框(坐标已含 margin,无需再加)
|
|
262
378
|
if (hasTarget) {
|
|
263
379
|
const rx = Math.max(8, a.lw * 1.8);
|
|
264
380
|
parts.push(`
|
|
@@ -267,7 +383,7 @@ function buildHtml({
|
|
|
267
383
|
vector-effect="non-scaling-stroke" />`);
|
|
268
384
|
}
|
|
269
385
|
|
|
270
|
-
//
|
|
386
|
+
// 文本框:白底固定深色文字(避免默认红字 + 白底 = 白字白底不可读)
|
|
271
387
|
if (hasText) {
|
|
272
388
|
const rx = Math.max(6, a.lw * 1.5);
|
|
273
389
|
parts.push(`
|
|
@@ -279,8 +395,8 @@ function buildHtml({
|
|
|
279
395
|
vector-effect="non-scaling-stroke" />`);
|
|
280
396
|
parts.push(`
|
|
281
397
|
<text x="${slot.bx + bw / 2}" y="${slot.by + bh / 2}" font-family="system-ui, -apple-system, 'PingFang SC', 'Microsoft YaHei', sans-serif"
|
|
282
|
-
font-size="${
|
|
283
|
-
fill="
|
|
398
|
+
font-size="${fontSize}" font-weight="600" text-anchor="middle" dominant-baseline="central"
|
|
399
|
+
fill="#111827">${escapeHtml(a.text)}</text>`);
|
|
284
400
|
}
|
|
285
401
|
|
|
286
402
|
// 箭头:文本框中心 → 目标中心
|
|
@@ -289,9 +405,14 @@ function buildHtml({
|
|
|
289
405
|
const dx = tx - sx;
|
|
290
406
|
const dy = ty - sy;
|
|
291
407
|
const len = Math.sqrt(dx * dx + dy * dy);
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
408
|
+
// ⚠️ 只在 hasText 时画箭头:text 是可选字段({x,y,w,h} 只想画高亮框的用法),
|
|
409
|
+
// 无 text 时 bw=bh=0、slot 落在目标中心,此时画箭头等于从目标中心画一条无来源说明的线,
|
|
410
|
+
// 且 slot.name 兜底为 "up" 会让箭头起点「避开文本框」的偏移逻辑指向一个不存在的框,
|
|
411
|
+
// 生成无标签来源的误导性标注。因此无 text 时只画高亮框、不画箭头。
|
|
412
|
+
if (hasText && len > 1) {
|
|
413
|
+
// 起点避开文本框:上/下方位沿垂直外移 bh/2,左/右方位沿水平外移 bw/2
|
|
414
|
+
const halfExtent = slot.name === "up" || slot.name === "down" ? bh * 0.5 : bw * 0.5;
|
|
415
|
+
const star = Math.min(1, Math.max(0, (halfExtent + a.lw * 2) / len));
|
|
295
416
|
const ex = (star * dx) / len;
|
|
296
417
|
const ey = (star * dy) / len;
|
|
297
418
|
const sx2 = sx + ex * len;
|
|
@@ -311,23 +432,27 @@ function buildHtml({
|
|
|
311
432
|
}
|
|
312
433
|
}
|
|
313
434
|
|
|
314
|
-
//
|
|
435
|
+
// 标题条:画在图片上方的 margin 留白区(不遮挡截图顶部,保证「URL 可见」)
|
|
436
|
+
// 传了 --label 或 --url 任一就渲染标题条:单独传 --url(仅需 URL 证据、不要文字标签)也必须生效,避免被静默忽略
|
|
315
437
|
let bar = "";
|
|
316
|
-
if (label) {
|
|
317
|
-
const barH = Math.max(40, Math.round(imageH * 0.05));
|
|
318
|
-
|
|
438
|
+
if (label || url) {
|
|
439
|
+
const barH = Math.min(Math.max(40, Math.round(imageH * 0.05)), Math.max(24, margin));
|
|
440
|
+
// 字号必须由 barH 反推,保证字放得进标题条:宽而矮的横向图(如 3840×400 局部截图)上
|
|
441
|
+
// barH 可能被 margin 上限压得很小(24px),若仍按 imageW/55 取(≈70px)文字会被 overflow:hidden 裁掉。
|
|
442
|
+
// 用 Math.min(barH * 0.55, imageW / 55) 让字号受 barH 约束,同时保底 20px。
|
|
443
|
+
const fontSize = Math.max(20, Math.min(Math.round(barH * 0.55), Math.round(imageW / 55)));
|
|
319
444
|
const lc = /^#[0-9a-fA-F]{6}$/.test(labelColor) ? labelColor : "#f43f5e";
|
|
320
445
|
const urlText = url ? escapeHtml(url) : "";
|
|
321
446
|
bar = `
|
|
322
|
-
<rect x="${margin}" y="${margin}" width="${imageW}" height="${barH}" rx="10" ry="10"
|
|
447
|
+
<rect x="${margin}" y="${margin - barH}" width="${imageW}" height="${barH}" rx="10" ry="10"
|
|
323
448
|
fill="${lc}" fill-opacity="0.9" />
|
|
324
|
-
<text x="${margin + imageW / 2}" y="${margin + barH / 2}" font-family="system-ui, sans-serif"
|
|
325
|
-
font-size="${
|
|
326
|
-
fill="#ffffff">${escapeHtml(label)}</text>
|
|
449
|
+
<text x="${margin + imageW / 2}" y="${margin - barH + barH / 2}" font-family="system-ui, sans-serif"
|
|
450
|
+
font-size="${fontSize}" font-weight="700" text-anchor="middle" dominant-baseline="central"
|
|
451
|
+
fill="#ffffff">${escapeHtml(label || "")}</text>
|
|
327
452
|
${
|
|
328
453
|
urlText
|
|
329
|
-
? `<text x="${margin + imageW - 14}" y="${margin + barH / 2}" font-family="ui-monospace, Menlo, monospace"
|
|
330
|
-
font-size="${Math.round(
|
|
454
|
+
? `<text x="${margin + imageW - 14}" y="${margin - barH + barH / 2}" font-family="ui-monospace, Menlo, monospace"
|
|
455
|
+
font-size="${Math.round(fontSize * 0.55)}" text-anchor="end" dominant-baseline="central"
|
|
331
456
|
fill="#ffffff" fill-opacity="0.92">${urlText}</text>`
|
|
332
457
|
: ""
|
|
333
458
|
}`;
|
|
@@ -359,13 +484,6 @@ function buildHtml({
|
|
|
359
484
|
function rectsOverlap(x1, y1, w1, h1, x2, y2, w2, h2) {
|
|
360
485
|
return !(x1 + w1 < x2 || x2 + w2 < x1 || y1 + h1 < y2 || y2 + h2 < y1);
|
|
361
486
|
}
|
|
362
|
-
function textContrast(colorHex) {
|
|
363
|
-
const c = colorHex.replace("#", "");
|
|
364
|
-
const r = parseInt(c.slice(0, 2), 16) || 0;
|
|
365
|
-
const g = parseInt(c.slice(2, 4), 16) || 0;
|
|
366
|
-
const b = parseInt(c.slice(4, 6), 16) || 0;
|
|
367
|
-
return (0.2126 * r + 0.7152 * g + 0.0722 * b) > 140 ? "#1a1a1a" : "#ffffff";
|
|
368
|
-
}
|
|
369
487
|
}
|
|
370
488
|
|
|
371
489
|
// ---------------------------------------------------------------- 主流程
|
|
@@ -404,18 +522,86 @@ function main() {
|
|
|
404
522
|
console.error("❌ --json 必须是标注数组");
|
|
405
523
|
process.exit(1);
|
|
406
524
|
}
|
|
525
|
+
// ⚠️ 校验元素类型:--json '[null]' 或含字符串/数字元素时,后面 a.x/a.y 会抛未捕获 TypeError(英文堆栈),
|
|
526
|
+
// 与其他参数错误的「❌ 中文提示 + exit 1」风格不一致。必须在换算前统一拦截。
|
|
527
|
+
if (annotations.some((a) => a === null || typeof a !== "object")) {
|
|
528
|
+
console.error("❌ --json 数组的每个元素都必须是对象({x,y,...}),不能含 null 或非对象元素");
|
|
529
|
+
process.exit(1);
|
|
530
|
+
}
|
|
407
531
|
}
|
|
408
532
|
|
|
409
533
|
// 坐标换算:CSS 坐标 → 设备像素
|
|
410
|
-
|
|
411
|
-
|
|
534
|
+
// 三种模式:
|
|
535
|
+
// - 默认:--json 的 x/y/w/h 视为「截图设备像素坐标」,×1 直接用;
|
|
536
|
+
// - --viewport "cssW,cssH":非 fullPage 视口截图,坐标是 CSS 视口坐标,按 (截图宽/视口宽) 缩放;
|
|
537
|
+
// - --fullpage + --dpr N:fullPage 全页截图,坐标是整页 CSS 坐标,按 DPR 等比缩放。
|
|
538
|
+
// ⚠️ fullPage 下绝不能按 imageH/viewport.h 缩放(viewport.h 只是视口高,不是整页高,会把 Y 轴放错)。
|
|
539
|
+
let scaleX = 1;
|
|
540
|
+
let scaleY = 1;
|
|
541
|
+
// ⚠️ --viewport 被 parseArgs 置为 true(后面紧跟另一个 --xxx 或位于末尾 = 没带值)时,
|
|
542
|
+
// 必须报错而不是静默跳过换算——否则坐标按设备像素走、用户却以为按视口换算了,标注整体错位且无任何提示。
|
|
543
|
+
if (keyed.viewport === true) {
|
|
544
|
+
console.error('❌ --viewport 必须显式传值 "cssW,cssH"(如 --viewport "1440,900"),当前为无值参数');
|
|
545
|
+
process.exit(1);
|
|
546
|
+
}
|
|
547
|
+
// ⚠️ --fullpage 与 --viewport 是互斥的两种换算模式(DPR 等比 vs 截图/视口比例),同传时按谁走都会让另一方的坐标全歪且无提示。
|
|
548
|
+
// 必须在代码里 exit 1,不能靠文档约束(SKILL.md 里虽强调"禁止 fullPage 用 --viewport",但使用者仍可能同传)。
|
|
549
|
+
if (keyed.fullpage && keyed.viewport && keyed.viewport !== true) {
|
|
550
|
+
console.error("❌ --fullpage 与 --viewport 互斥,不能同时使用");
|
|
551
|
+
process.exit(1);
|
|
552
|
+
}
|
|
553
|
+
// ⚠️ --dpr 只对 --fullpage 模式生效(整页 CSS 坐标按 DPR 等比缩放)。默认/--viewport 模式下坐标已按
|
|
554
|
+
// 设备像素或「截图宽/视口宽」换算,--dpr 不参与计算;此时传 --dpr 会被静默忽略、scale 保持 1,
|
|
555
|
+
// 标注整体按 1 倍画出来且无任何提示——正是本脚本要根治的「静默错位」。与其他参数校验一致 fail-fast,
|
|
556
|
+
// 不能靠文档约束(SKILL.md 里 --fullpage --dpr N 连写,使用者漏打 --fullpage 就会命中)。
|
|
557
|
+
if (keyed.dpr !== undefined && !keyed.fullpage) {
|
|
558
|
+
console.error("❌ --dpr 只在 --fullpage 模式下生效;非 fullPage 模式坐标已按设备像素/视口换算,无需 --dpr。请移除 --dpr 或补上 --fullpage。");
|
|
559
|
+
process.exit(1);
|
|
560
|
+
}
|
|
561
|
+
if (keyed.fullpage) {
|
|
562
|
+
// fullPage 模式依赖 DPR 把整页 CSS 坐标换算成设备像素。缺 --dpr 时 DPR=1 是合法场景
|
|
563
|
+
// (fullPage 截图恰好 DPR=1),但显式传了非正数/非数字必须报错,禁止静默降级导致标注整体偏移。
|
|
564
|
+
// ⚠️ 必须区分「缺省(undefined/true/"")」与「显式传了非法值(如 abc/2x/-1/0)」:
|
|
565
|
+
// num() 会把显式非法值也归一化成默认值 1,仅凭 dpr>0 校验判断不出「非法值被静默降级」,
|
|
566
|
+
// 因此要在 num() 之外单独检查「显式传值但非有限正数」并 exit 1。
|
|
567
|
+
const dprExplicit = keyed.dpr !== undefined && keyed.dpr !== true && keyed.dpr !== "";
|
|
568
|
+
if (dprExplicit && !(Number.isFinite(Number(keyed.dpr)) && Number(keyed.dpr) > 0)) {
|
|
569
|
+
console.error(`❌ --dpr 必须是正数,收到 "${keyed.dpr}"`);
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
const dpr = num(keyed.dpr, 1);
|
|
573
|
+
if (!(dpr > 0)) {
|
|
574
|
+
console.error("❌ --dpr 必须为正数");
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
if (!dprExplicit) {
|
|
578
|
+
console.warn("⚠️ 未传 --dpr,按 1 处理。若 fullPage 截图时 DPR≠1(如浏览器缩放渲染),坐标会整体偏移,请显式传 --dpr N。");
|
|
579
|
+
}
|
|
580
|
+
scaleX = dpr;
|
|
581
|
+
scaleY = dpr;
|
|
582
|
+
} else if (keyed.viewport && keyed.viewport !== true) {
|
|
412
583
|
const [vw, vh] = String(keyed.viewport).split(",").map(Number);
|
|
413
584
|
if (Number.isFinite(vw) && Number.isFinite(vh) && vw > 0 && vh > 0) {
|
|
414
|
-
|
|
585
|
+
scaleX = imageW / vw;
|
|
586
|
+
scaleY = imageH / vh;
|
|
587
|
+
} else {
|
|
588
|
+
console.error(`❌ --viewport 格式应为 "cssW,cssH",收到 "${keyed.viewport}"`);
|
|
589
|
+
process.exit(1);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
// 必填坐标校验:x/y 是标注定位的必填字段,缺失/非法值时若静默归零会把箭头/框画到左上角且无任何提示
|
|
593
|
+
// (正是本脚本要根治的「静默错位」)。w/h 是可选(缺省 0 = 只画点不画框),不在此列。
|
|
594
|
+
for (const a of annotations) {
|
|
595
|
+
for (const f of ["x", "y"]) {
|
|
596
|
+
const v = a[f];
|
|
597
|
+
if (v === undefined || v === null || v === "" || v === true || !Number.isFinite(Number(v))) {
|
|
598
|
+
console.error(
|
|
599
|
+
`❌ 标注数组第 ${annotations.indexOf(a) + 1} 项的 "${f}" 缺失或非法(收到: ${JSON.stringify(v)}),x/y 必须是有穷数字`,
|
|
600
|
+
);
|
|
601
|
+
process.exit(1);
|
|
602
|
+
}
|
|
415
603
|
}
|
|
416
604
|
}
|
|
417
|
-
const scaleX = viewport ? imageW / viewport.w : 1;
|
|
418
|
-
const scaleY = viewport ? imageH / viewport.h : 1;
|
|
419
605
|
annotations = annotations.map((a) => ({
|
|
420
606
|
...a,
|
|
421
607
|
x: num(a.x, 0) * scaleX,
|
|
@@ -427,7 +613,8 @@ function main() {
|
|
|
427
613
|
const marginFrac = num(keyed["margin-frac"], 0.05);
|
|
428
614
|
const html = buildHtml({
|
|
429
615
|
imagePath,
|
|
430
|
-
|
|
616
|
+
// MIME 由文件内容魔数决定而非扩展名,避免「JPEG 内容误命名 .png」生成 data:image/png;base64 渲染异常
|
|
617
|
+
imageMime: detectMime(buf, imagePath),
|
|
431
618
|
imageW,
|
|
432
619
|
imageH,
|
|
433
620
|
annotations,
|
|
@@ -438,7 +625,8 @@ function main() {
|
|
|
438
625
|
});
|
|
439
626
|
|
|
440
627
|
fs.writeFileSync(outHtml, html);
|
|
441
|
-
|
|
628
|
+
// 与 buildHtml 里同一套公式(短边 + 绝对上限 200px),保证打印的画布尺寸与实际一致
|
|
629
|
+
const margin = Math.max(24, Math.min(200, Math.round(Math.min(imageW, imageH) * marginFrac)));
|
|
442
630
|
console.log(
|
|
443
631
|
`✅ 已生成标注 HTML → ${outHtml}`,
|
|
444
632
|
);
|
|
@@ -32,7 +32,7 @@ description: >-
|
|
|
32
32
|
|
|
33
33
|
| 改动类型 | 证据来源 | 具体动作 |
|
|
34
34
|
|------|------|------|
|
|
35
|
-
| 页面 / 界面改动 | 真实页面 | 打开真实页面,用真实数据走真实交互(点击、填写、提交、等待渲染),全页截图 +
|
|
35
|
+
| 页面 / 界面改动 | 真实页面 | 打开真实页面,用真实数据走真实交互(点击、填写、提交、等待渲染),全页截图 + 箭头标注关键改动区域 |
|
|
36
36
|
| Redis 相关 | Redis for VS Code 插件 | 查看运行时 Redis 的 key/value,A/B 对比改动前后的值(SET 已知值 → 触发业务动作 → 截图看值是否如预期) |
|
|
37
37
|
| 数据库相关 | SQLTools 插件 | 查看运行时数据库的 row 数据,A/B 对比改动前后的值(插入/更新 → 触发业务动作 → 截图看落库是否如预期) |
|
|
38
38
|
| 纯后端接口 / 定时任务无页面 | 运行时真实返回 | curl / 日志 / 查库 / 查 Redis,把结果渲染成可视化证据(暗色终端风格的请求/响应对比 HTML 截图,或 Redis/SQLTools 截图) |
|
|
@@ -53,7 +53,7 @@ description: >-
|
|
|
53
53
|
2. **「为什么这样验证成立」原理说明卡**:先讲清楚 bug/功能差异的本质是哪一个动作,再论证「手动模拟该动作 = 真实场景」
|
|
54
54
|
3. **A/B 对照表**:修复前 vs 修复后(或改动前 vs 改动后),代码行为 / 等价操作 / 观测结果三列并排
|
|
55
55
|
4. **每步截图**:必须带箭头标注关键区域/验证点,图注写清「这张图证明了什么」,标注关键行/关键数据
|
|
56
|
-
- 截图本身遵循「⚠️
|
|
56
|
+
- 截图本身遵循「⚠️ 截图规范」:浏览器真实视口宽(需要更清晰时提高 DPR)、`fullPage` 全页、URL 可见、存到 `docs/` 对应子目录、文件名「编号 + 英文描述」。
|
|
57
57
|
|
|
58
58
|
## 不写测试 ≠ 不查错
|
|
59
59
|
|
|
@@ -70,4 +70,4 @@ description: >-
|
|
|
70
70
|
|
|
71
71
|
- ⚠️ 第一反应不是写代码、也不是写测试,而是「想清楚怎么用证据证明功能对了」
|
|
72
72
|
- ⚠️ 验证报告必须先给用户看、用户确认无误才算完成,禁止 AI 自认为「看起来对」就宣称完成
|
|
73
|
-
- ⚠️ 带得动的改动(页面/Redis/数据库)必须走可视化验证;只有带不动又不属于上面两类的,才需要向用户说明并请用户决定
|
|
73
|
+
- ⚠️ 带得动的改动(页面/Redis/数据库)必须走可视化验证;只有带不动又不属于上面两类的,才需要向用户说明并请用户决定
|