@routerhub/agent-rules 1.5.151 → 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 CHANGED
@@ -396,6 +396,7 @@
396
396
  - 🔴 **会导致实际 bug / 数据丢失 / 数据错误 / 安全漏洞**:有明确触发路径(真实输入、真实调用链能复现),读 diff 能确认后果。
397
397
  - 🟡 **真实风险**:有明确触发条件,且触发后后果严重(如并发竞态、错误被静默吞掉、未处理空值导致崩溃)。
398
398
  - **违反本项目已固化规则**:AGENTS.base.md / AGENTS.private.md 里的 ⚠️ 铁律(DELETE 铁律、部署自包含、数据链路核对、环境配置禁止推断、测试环境验证前置等),diff 中明确违反 → 必须报。
399
+ - **外部事实核查确认的配置错误**:按「审查方法 → ④ 外部事实核查」查出的事实性错误(死域名、环境归属不一致、对外死值等),即使 diff 内部自洽也必须报——这正是「指向错误环境」类问题的唯一暴露途径(呼应「环境配置禁止推断」)。
399
400
 
400
401
  ### 禁止报(不是问题,报了只会拉长循环)
401
402
 
@@ -441,6 +442,16 @@
441
442
  ⚠️ PR 描述有「行为变更表 / 改前改后对比表」时,审查以它为靶心,逐行问「这条真的成立吗」,用 diff 逐行验证;PR 没列表的,review 意见至少覆盖「改了什么行为 / 不变的行为有没有被误伤」两个面。
442
443
  ⚠️ 对作者标注「不在本 PR 范围」的内容(如相关子系统计费、其它仓库同步):不要求作者修,但必须判断是否构成「已知缺口被静默放过」——已暴露的边界缺口,作者必须在 PR 里显式记录后续动作、或至少点明仍待处理,禁止「提了不等于了」。
443
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
+
444
455
  **与审稿边界的配合**:本章找出的问题,是否上报仍按「必须报 / 禁止报」判断(真问题、真实风险、违反铁律 → 报;口味、防御、无法确认真假 → 不报)。每条意见必须带「新增量全局跟进」的链路证据(具体 diff 行 + 谁赋值谁读谁落库),禁止只给结论不给路径。
445
456
 
446
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@routerhub/agent-rules",
3
- "version": "1.5.151",
3
+ "version": "1.5.152",
4
4
  "description": "Shared Copilot agent rules and guidelines for RouterHub projects",
5
5
  "main": "AGENTS.base.md",
6
6
  "bin": {
@@ -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
 
@@ -58,6 +59,16 @@ outputName: "review-boundary"
58
59
  ⚠️ PR 描述有「行为变更表 / 改前改后对比表」时,审查以它为靶心,逐行问「这条真的成立吗」,用 diff 逐行验证;PR 没列表的,review 意见至少覆盖「改了什么行为 / 不变的行为有没有被误伤」两个面。
59
60
  ⚠️ 对作者标注「不在本 PR 范围」的内容(如相关子系统计费、其它仓库同步):不要求作者修,但必须判断是否构成「已知缺口被静默放过」——已暴露的边界缺口,作者必须在 PR 里显式记录后续动作、或至少点明仍待处理,禁止「提了不等于了」。
60
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
+
61
72
  **与审稿边界的配合**:本章找出的问题,是否上报仍按「必须报 / 禁止报」判断(真问题、真实风险、违反铁律 → 报;口味、防御、无法确认真假 → 不报)。每条意见必须带「新增量全局跟进」的链路证据(具体 diff 行 + 谁赋值谁读谁落库),禁止只给结论不给路径。
62
73
 
63
74
  ---
@@ -44,29 +44,59 @@ git push origin "$ORIGINAL_BRANCH"
44
44
 
45
45
  ```bash
46
46
  # 确认本地功能分支与远程已同步(本地无未推送提交)
47
- # 先 fetch 让 origin/<分支> 引用最新,再用「分支的 upstream..HEAD」精确列举未推送提交
48
- # ⚠️ 分支可能从未推送过(远程不存在该 ref),git fetch origin "$X" 会报 "couldn't find remote ref";
49
- # 2>/dev/null || true 容忍该失败,否则在 set -e 环境下会直接中断、后面的「远程分支不存在」兜底逻辑根本执行不到
50
- git fetch origin "$ORIGINAL_BRANCH" 2>/dev/null || true
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
51
57
  git status
52
- # 无 upstream 时 @{u} 会报错,先检测:有 upstream 用 @{u} 精确差集;
53
- # upstream 时先确认远程分支是否存在(git log origin/X..HEAD 对不存在的引用会直接 fatal),
54
- # 不存在 = 新分支从未推送。⚠️ 此时不能用 git log HEAD(会把 main 全部历史也算进来,输出上千行淹没真正关心的提交),
55
- # 要相对默认分支(origin/main)取差集,只列出本分支相对默认分支的新增提交
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} 只在「功能分支从未以自己名字推送过」时作降级。
56
67
  DEFAULT_BRANCH=$(git remote show origin | grep 'HEAD branch' | cut -d: -f2 | tr -d ' ')
57
- if git rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
58
- UNPUSHED=$(git log "@{u}"..HEAD --oneline)
59
- elif git rev-parse --verify "origin/$ORIGINAL_BRANCH" >/dev/null 2>&1; then
60
- UNPUSHED=$(git log "origin/$ORIGINAL_BRANCH"..HEAD --oneline)
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}"
61
72
  elif git rev-parse --verify "origin/$DEFAULT_BRANCH" >/dev/null 2>&1; then
62
- UNPUSHED=$(git log "origin/$DEFAULT_BRANCH"..HEAD --oneline)
73
+ BASE_REF="origin/$DEFAULT_BRANCH"
63
74
  else
64
- UNPUSHED=$(git log HEAD --oneline)
75
+ # 远程连功能分支与默认分支引用都没有(全新仓库/远程为空)→ 当前分支必然从未推送。
76
+ # ⚠️ 不能用 git log HEAD 兜底:会把 main 全部历史也算进来,输出上千行淹没真正关心的提交;直接阻断并提示。
77
+ echo "❌ 远程找不到 ${ORIGINAL_BRANCH} 与 ${DEFAULT_BRANCH} 分支引用,无法比对推送状态;请先 git push origin ${ORIGINAL_BRANCH} 再部署"
78
+ exit 1
65
79
  fi
66
- [ -z "$UNPUSHED" ] && echo "✅ 无未推送提交" || { echo "⚠️ 存在未推送提交:"; echo "$UNPUSHED"; }
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
+ }
67
97
  ```
68
98
 
69
- ⚠️ `git log @{u}..HEAD`(当前分支与其 upstream 的差集)而不是 `git log origin/"$ORIGINAL_BRANCH".."$ORIGINAL_BRANCH"`:后者在本地分支尚未 fetch 到最新 origin 时会把已推送过的提交误报为「未推送」,导致误判;且若分支创建时 track 的不是同名远程分支,`origin/X` 引用可能缺失。`@{u}` 始终指向当前分支真正跟踪的远程引用,语义更准确。⚠️ 但 `@{u}` 仅在分支设置了 upstream(`git push -u`)时可用;功能分支若用 `git push origin <branch>` 推送则无 upstream,`@{u}` 会直接报错,因此上面先用 `git rev-parse --abbrev-ref "@{u}"` 检测。无 upstream 时还要先确认远程分支存在——`git log origin/X..HEAD` 对**不存在的引用会直接 fatal**(而不是返回空),因此用 `git rev-parse --verify "origin/X"` 探测;远程分支不存在即新分支从未推送,此时全部本地提交都算「未推送」。
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}` 与默认分支引用都不存在(全新仓库)时无法比对,直接阻断要求先推送。
70
100
 
71
101
  - ⚠️ 若 review 修复还没合进当前功能分支 → 先把修复 commit 到功能分支再推送,禁止带着"修复只在 test 分支"的状态去部署。
72
102
  - ⚠️ feature 分支与 test 分支两条线必须保持同步:**所有 review 修复代码必须先落 feature 分支(PR 分支),再合并到 test**,缺一不可。
@@ -43,9 +43,13 @@ echo "最新 review 审查 commit: $LATEST"
43
43
  echo "当前 HEAD: $CUR"
44
44
  ```
45
45
 
46
- `check-review.sh` 返回的最新 review 审查 commit 对比:若等于当前 HEAD,说明 bot 已审查到最新代码(循环 review 已对该 commit 收敛);若为空或更早,说明 bot 还没审到最新 push,循环 review 尚未完成。
46
+ `check-review.sh` 返回的 LATEST 只回答「最新一轮 AI review **审查了哪个 commit**」,它只证明「bot 已到达当前 HEAD」,**不**证明「该轮 review 里没有值得修的问题」。因此「LATEST = CUR」只是**必要条件**,不能单独作为「循环 review 已收敛」的判据。
47
47
 
48
- ⚠️ 不要用 `gh pr view --json comments -q '.comments | length'`(评论总数)判断闭环——评论数与「循环 review 是否收敛」无对应关系,验证不了。判断依据是「最新 review 是否针对当前 HEAD」,即 `/loop-review` 步骤 1 的同一套逻辑。若循环 review 还没走完,**先补跑 `/loop-review`,不得跳过**。
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`,不得跳过**。
49
53
 
50
54
  ### 2. 确保最新代码已推送
51
55
 
@@ -109,6 +109,13 @@ function jpegSize(buf) {
109
109
  continue;
110
110
  }
111
111
  const marker = buf[pos + 1];
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
+ }
112
119
  // 无长度字段的标记段:SOI(FFD8)/EOI(FFD9)/TEM(FF01)/RSTn(FFD0-D7) 等,跳过 2 字节即可。
113
120
  // 若不跳过会把后续两字节误当段长,pos 跳到错误位置,甚至解析出错误的宽高。
114
121
  if (
@@ -209,6 +216,30 @@ function detectSize(filePath, buf) {
209
216
  );
210
217
  }
211
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);
241
+ }
242
+
212
243
  // ---------------------------------------------------------------- HTML 安全
213
244
  function escapeHtml(s) {
214
245
  return String(s)
@@ -233,13 +264,21 @@ function extMime(filePath) {
233
264
  }
234
265
 
235
266
  // ---------------------------------------------------------------- 布局工具(生成 SVG 常量)
236
- // 文字排版:根据中文字符数近似估算宽度(一个汉字≈1.0em,拉丁≈0.55em
267
+ // 文字排版:按字符近似估算宽度(汉字/全角≈1.0em,拉丁≈0.55em,常用符号/emoji≈全宽)
237
268
  function estimateTextMetrics(text, fontSize) {
238
269
  const chars = [...text];
239
270
  let w = 0;
240
271
  for (const ch of chars) {
241
272
  const code = ch.codePointAt(0);
242
- const width = code > 0x2e80 ? fontSize : fontSize * 0.55;
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;
243
282
  w += width;
244
283
  }
245
284
  return { width: Math.ceil(w) + 2 * fontSize, height: Math.ceil(fontSize * 1.7) };
@@ -319,7 +358,18 @@ function buildHtml({
319
358
  break;
320
359
  }
321
360
  }
322
- if (!slot) slot = cands[0];
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
+ }
323
373
 
324
374
  const hasTarget = a.w > 0 && a.h > 0;
325
375
  const hasText = Boolean(a.text);
@@ -355,7 +405,11 @@ function buildHtml({
355
405
  const dx = tx - sx;
356
406
  const dy = ty - sy;
357
407
  const len = Math.sqrt(dx * dx + dy * dy);
358
- if (len > 1) {
408
+ // ⚠️ 只在 hasText 时画箭头:text 是可选字段({x,y,w,h} 只想画高亮框的用法),
409
+ // 无 text 时 bw=bh=0、slot 落在目标中心,此时画箭头等于从目标中心画一条无来源说明的线,
410
+ // 且 slot.name 兜底为 "up" 会让箭头起点「避开文本框」的偏移逻辑指向一个不存在的框,
411
+ // 生成无标签来源的误导性标注。因此无 text 时只画高亮框、不画箭头。
412
+ if (hasText && len > 1) {
359
413
  // 起点避开文本框:上/下方位沿垂直外移 bh/2,左/右方位沿水平外移 bw/2
360
414
  const halfExtent = slot.name === "up" || slot.name === "down" ? bh * 0.5 : bw * 0.5;
361
415
  const star = Math.min(1, Math.max(0, (halfExtent + a.lw * 2) / len));
@@ -468,6 +522,12 @@ function main() {
468
522
  console.error("❌ --json 必须是标注数组");
469
523
  process.exit(1);
470
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
+ }
471
531
  }
472
532
 
473
533
  // 坐标换算:CSS 坐标 → 设备像素
@@ -478,6 +538,26 @@ function main() {
478
538
  // ⚠️ fullPage 下绝不能按 imageH/viewport.h 缩放(viewport.h 只是视口高,不是整页高,会把 Y 轴放错)。
479
539
  let scaleX = 1;
480
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
+ }
481
561
  if (keyed.fullpage) {
482
562
  // fullPage 模式依赖 DPR 把整页 CSS 坐标换算成设备像素。缺 --dpr 时 DPR=1 是合法场景
483
563
  // (fullPage 截图恰好 DPR=1),但显式传了非正数/非数字必须报错,禁止静默降级导致标注整体偏移。
@@ -509,6 +589,19 @@ function main() {
509
589
  process.exit(1);
510
590
  }
511
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
+ }
603
+ }
604
+ }
512
605
  annotations = annotations.map((a) => ({
513
606
  ...a,
514
607
  x: num(a.x, 0) * scaleX,
@@ -520,7 +613,8 @@ function main() {
520
613
  const marginFrac = num(keyed["margin-frac"], 0.05);
521
614
  const html = buildHtml({
522
615
  imagePath,
523
- imageMime: extMime(imagePath),
616
+ // MIME 由文件内容魔数决定而非扩展名,避免「JPEG 内容误命名 .png」生成 data:image/png;base64 渲染异常
617
+ imageMime: detectMime(buf, imagePath),
524
618
  imageW,
525
619
  imageH,
526
620
  annotations,