Skip to content

fix(search): 高亮改为在原文上定位命中,修掉错高亮与实体边界 - #111

Merged
ROM4n2 merged 1 commit into
masterfrom
fix/search-highlight-hit-alignment
Oct 6, 2026
Merged

ROM4n2 merged 1 commit into
masterfrom
fix/search-highlight-hit-alignment

Conversation

@ROM4n2

@ROM4n2 ROM4n2 commented Oct 6, 2026

Copy link
Copy Markdown
Owner

缺陷

static/js/search.js::_highlight 旧实现是「先 esc() 再在已转义串上定位」(safe / safeNeedle)。命中区间因此是"转义后的形态",两个后果:

  1. 实体边界被切开 —— & → &,高亮范围与用户所见的字符不对应。
  2. 错高亮(更严重) —— 大小写折叠会改变长度(İ.toLowerCase() 长度 1→2),折叠串索引与原文整体错位。实测(切真实实现丢 node 跑 224 组 text × needle):
text = "İstanbul"  needle = "T"  →  html = "İst<mark>a</mark>nbul"   # 高亮到了字母 a
text = "İstanbul"  needle = "i"  →  html = "<mark>İ</mark>stanbul"    # İ 与 i 并非同一个字母

修法

在未转义原文上定位命中,切片后逐段 esc() 再包 <mark>;比较走码点数组([...raw] + 逐码点 toLowerCase,长度变化时保留原码点 ⇒ 宁可漏高亮也不错高亮),规避 String.slice 的 UTF-16 计数(𝔘 占 2)造成的错位。

XSS 不变式反而更清楚:<mark> 是本函数唯一插入的标签,每一段都过 esc()。

守卫(实质保证走行为探针,红线 11)

tools/wb_search_probe.mjs 新增场景 highlight_hit_alignment:13 组 text/needle × 三条不变式(去 <mark> 后反转义 === 原文;每个 <mark> 内反转义 === needle;输出无可执行 <script>)+ 两条等值断言。另加两条反回退切片检查(旧标志 safe/safeNeedle MUST NOT 再现;MUST 含码点级展开)。tests/test_search_ui.py 的旧 esc(text 字面守卫改写为形态反回归守卫,并把新场景加进关键场景名清单。

守卫自证:把 search.js 单独 stash 回退旧实现后探针按预期抛错、EXITCODE=1;恢复后全绿。临时复现脚本枚举 598 组用例,异常 0。

门禁

  • wb_search_probe.mjs 10/10;28 个 tools/*.mjs 探针全绿
  • 半 A 1053 passed / 半 B 264 passed + 1 skipped ⇒ 全量 1317 + 1 skipped(与 v5.16.0 同数:本轮重写一条测试、未新增测试函数)
  • ruff check . 0;mypy --follow-imports=skip tests 93 files / mypy --strict delector tools 70 files 零错误
  • pre-commit 密钥扫描已启用(core.hooksPath=.githooks),本次提交未跳过

备注

只动 static/、tools/、tests/ 与 WORKMEMORY/,未发版(发布面五件套未动)。PROJECT_OVERVIEW 开放待办里已划掉这条递延项。

旧实现是「先 esc() 再在已转义串上定位」(safe / safeNeedle),命中区间
因此是「转义后的形态」:& → &amp; 的实体边界被切开(高亮范围与用户所见的
字符不对应);更严重的是大小写折叠会改变长度(İ → i̇,1 → 2),折叠串
索引与原文整体错位 —— 原文「İstanbul」搜「T」会高亮到字母「a」。

改为在**未转义原文**上定位命中,切片后**逐段** esc() 再包 <mark>;比较走
码点数组([...raw] + 逐码点 toLowerCase,长度变化时保留原码点 ⇒ 宁可漏
高亮也不错高亮),规避 String.slice 的 UTF-16 计数(𝔘 占 2)带来的错位。
XSS 不变式更清楚:<mark> 是本函数唯一插入的标签,每一段都过 esc()。

守卫(按红线 11「字符串存在断言是死测」,实质保证走行为探针):
- tools/wb_search_probe.mjs 新增场景 highlight_hit_alignment:13 组
  text/needle × 三条不变式(去 <mark> 后反转义 === 原文 ⇒ 永不切开实体;
  每个 <mark> 内反转义 === needle ⇒ 不许错高亮;输出无可执行 <script>)
  + 两条等值断言(İstanbul 搜 t === İs<mark>t</mark>anbul;
  AT&T 搜 T&T === A<mark>T&amp;T</mark>)。
- 另加两条反回退切片检查:旧标志 safe/safeNeedle MUST NOT 再现;
  MUST 含码点级展开(长度对齐的前提)。
- tests/test_search_ui.py 的旧 esc(text 字面守卫随实现形态改写为形态反回归
  守卫,并把 highlight_hit_alignment 加进关键场景名清单(防删场景仍全绿)。

守卫自证:把 search.js 单独 stash 回退旧实现后,探针按预期抛错并
EXITCODE=1;恢复后全绿。临时复现脚本枚举 598 组用例,异常 0。

门禁:wb_search_probe 10/10、28 个 tools 探针全绿、半 A 1053 passed /
半 B 264 passed + 1 skipped(全量 1317 + 1 skipped,与 v5.16.0 同数:本轮
重写一条测试、未新增测试函数)、ruff 0、mypy 93 + 70 files 零错误。
@ROM4n2
ROM4n2 merged commit d5f9577 into master Oct 6, 2026
1 check passed
@ROM4n2
ROM4n2 deleted the fix/search-highlight-hit-alignment branch October 6, 2026 16:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant