dsh-perf-hud 0.4.0 → 0.5.0

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.md CHANGED
@@ -19,10 +19,10 @@
19
19
  | 模式 | 什么时候 | 做什么 | 成本 |
20
20
  |---|---|---|---|
21
21
  | **守夜**(默认常开) | 一直 | 帧级环形缓冲 + LoAF 观察器;自动留存**最差 10 次 + 每天最差 1 条**(最多 7 天)并落盘 | 零插桩:只挂本来就在跑的 rAF 与两个 `PerformanceObserver`(LoAF + 交互计时) |
22
- | **现场**(展开面板) | 想看一眼 | 概览(卡不卡 + 最差那条是谁)/ 回调归因 Top3 / 守夜那行;其余五组折叠(`state.groups` = `detail` / `cross` / `dom` / `host` / `tools`) | 可忽略 |
22
+ | **现场**(展开面板) | 想看一眼 | **主界面**只回答五个问题(当前状态 / 最近一次卡顿 / 可能原因 / 最近趋势 / 下一步);技术数据进**开发者详情**的五个标签页(帧与归因 / DOM 与堆 / 宿主与链路 / 工具箱 / 原始数据,内层仍是 `state.groups` = `detail` / `cross` / `dom` / `host` / `tools` / `tech`)。**卡顿详情**是"普通用户也会走到"的技术屏,所以它分两层:结论层常显(上下文标题 / 这条事故的两个动作 / 回放范围 / 归因状态 / 历史帧时间轴),技术读数全在默认收起的 `tech`(「技术细节」)里 —— 与开发者详情**共用同一份渲染**(`techGroup`) | 可忽略 |
23
23
  | **深录**(显式) | 要抓全 | 才打开原型插桩(DOM 写入 1/10 采样 + 调度点抓栈)与「精确归因」 | 有代价,必须显式开 |
24
24
 
25
- > ✅ **这张表现在是真的了(2026-10-08,D1)**:在那之前插桩没有独立开关 —— `start()` 一跑就把 `setTimeout/setInterval/rAF` 与 `appendChild/insertBefore/setAttribute` 全包上,「守夜 = 零插桩」只是文档上的话。现在插桩被拆进 `applyProbeState()`,只在 `state.deep`(标题栏「深录」)打开时装,深录同时管精确归因(两者是「要抓全」的同一件事)。实测:默认 `instrumented: false`,点开 → `true`,点回去 → `false`,面板 `errors` 全程 0。
25
+ > ✅ **这张表现在是真的了(2026-10-08,D1)**:在那之前插桩没有独立开关 —— `start()` 一跑就把 `setTimeout/setInterval/rAF` 与 `appendChild/insertBefore/setAttribute` 全包上,「守夜 = 零插桩」只是文档上的话。现在插桩被拆进 `applyProbeState()`,只在 `state.deep` 打开时装(开关在**开发者详情的标签页行**与设置页 —— 它是开发者开关,不该占标题栏那一行),深录同时管精确归因(两者是「要抓全」的同一件事)。实测:默认 `instrumented: false`,点开 → `true`,点回去 → `false`,面板 `errors` 全程 0。
26
26
 
27
27
  **明确不做**(生态里已经有人做了;重复做比谁都不做更糟,详见 §12):
28
28
 
@@ -32,10 +32,31 @@
32
32
 
33
33
  **仍然不做的事**:
34
34
 
35
- - 不碰 DSH 源码,不注册服务,不监听 `session/event`(除非用户显式开 `watchEvents`)。
35
+ - 不碰 DSH 源码,不注册服务。宿主半**启动时不监听** `session/event`:客户端在第一次成功拿到宿主数据时请求它装一次(每个页面会话一次),装没装上以宿主回执为准并写进面板与导出(`eventWatch.installed` + reason);不想要可以用 `__perfHud.hostEvents(false)` 关掉。口径是「提交(`event.time`)→ 到达监听器」的延迟,不是监听器 CPU 耗时。
36
36
  - 不写任何自己的持久化状态到 profile;面板状态只进 `localStorage`。
37
37
  - 不改 `cordis.patch.yml`(它只负责把本包挂进组合),不改 profile 的持久化配置行。
38
38
 
39
+ ### 0.2 与 DSH 自带开发者工具的分工(2026-10-17)
40
+
41
+ 本机 DSH `0.2.1-rc.1` 里有一个实验性的**开发者工具**组合包(`@deepseek-ai/dsh-experimental-inspector-profile`,默认关闭):
42
+ `NodeJS 诊断`(底部面板内嵌 Chrome DevTools:Host console / 断点 / Sources / Network / Performance / Memory / Cordis 树)
43
+ 与`会话数据诊断`(原始 session 日志 / chat group-node 树)。它是**前台显微镜**,不是记录器;边界也很明确:
44
+ Client 侧只有 Console 求值与只读的 `lib/client.js` Sources,**没有页面渲染 trace / 堆 / 帧数据**;Performance 只采 Host CPU;
45
+ 界面只有英文;Host fetch 抓包不脱敏。
46
+
47
+ 两边**不重合**,也不允许互相依赖:
48
+
49
+ | 想回答 | 用 |
50
+ |---|---|
51
+ | 现在为什么慢 / 断点 / 网络 / 原始会话日志 / Cordis 树 | 官方开发者工具 |
52
+ | 刚刚那一下有多慢、是谁、能不能复盘 | 本插件 |
53
+ | 回归 / 治理 / bisection | `dsh-performance-guard` / `dsh-perf-lens` / `@linxin666/dsh-perf` |
54
+
55
+ 三条硬性:① **不内嵌 DevTools、不连它的 CDP / Worker、不发布到 `ctx.inspector.publish`** —— 它是 experimental、可选、偏重,
56
+ 本插件的边界是"零运行时依赖 + 默认常开便宜";② 它抓全量 fetch **含密钥**,本插件的导出件继续走"减法脱敏";③
57
+ 它不给 Client 渲染 trace,所以事故包里的 `devtools-trace` 缺失**仍然成立**,不要把"装了开发者工具"写成"缺口已补"。
58
+ 完整对照与本轮采纳的三条可靠性规则见 [REVIEW.md](REVIEW.md) §3.6 与本文 §7.1。
59
+
39
60
  ---
40
61
 
41
62
  ## 1. 仓库硬约束
@@ -50,7 +71,7 @@
50
71
 
51
72
  ## 2. 环境与改动生效方式(决定你能不能验证)
52
73
 
53
- 基线环境:DSH **`0.2.0-rc.2`**,profile 目录 `~/.dsh/profiles/web`,本仓库以 `link:` 装进该 profile
74
+ 基线环境:DSH **`0.2.1-rc.1`**,profile 目录 `~/.dsh/profiles/web`,本仓库以 `link:` 装进该 profile
54
75
  (`dsh plugin --profile web add /Users/y/workspace/dsh-perf-hud`)—— **宿主加载的就是这个检出**,所以下面这张表直接决定"你改的东西什么时候真的在跑":
55
76
 
56
77
  | 改动 | 生效方式 |
@@ -106,8 +127,14 @@ git worktree remove .worktrees/i18n # 收工;分支若不要
106
127
  node tools/check-panel.mjs --expr="!!document.querySelector('svg[viewBox=\"0 0 60 18\"]')"
107
128
  node tools/check-storage.mjs # 写 localStorage → 重新加载 → 读回(落盘/跨会话基线)
108
129
  node tools/check-interaction.mjs # CDP 发真实输入 → 验 event timing 真的交付
130
+ node tools/check-incident.mjs # 抓一条真事故 → 逐帧回看 / 切回实时 / 刷新后仍能打开
131
+ node tools/check-counts.mjs # 计数跨刷新 + 口径不虚报 + 导出件里没有凭据(真 location 里塞 token)
132
+ node tools/shots.mjs # 改完**界面结构 / 文案**之后重出一遍 README 的九张截图(写进 docs/images/)
109
133
  ```
110
134
 
135
+ **什么时候必须跑 `check-incident.mjs`**:改完**历史事故回放 / 取证明细的上下文切换**就跑它 —— 它挡的是"上面历史事故、下面另一条实时帧"和"刷新后历史事故打不开"这类离线套件结构上盖不到的事故。它依赖两个 DOM 约定:根节点 `id="dsh-perf-hud-root"`(控件态样式的作用域)与详情区第一行的 `data-ctx-bar="1"`(验收器靠它读"现在是哪个上下文")—— **改这两个名字等于改验收器**,改之前先看 `tools/check-incident.mjs`。
136
+
137
+ ⚠️ **验收器被强杀会留下 headless Chrome 占着 `--user-data-dir` 与调试端口,后续跑会"挂死"**(2026-10-13 踩过):症状是 `waitForHud` 之后的第一次 `evaluate` 永不返回(不是超时报错,是**一直等**),因为新实例连上的是那个残留实例。处置:`pkill -f 'remote-debugging-port=93' && rm -rf /tmp/dsh-perf-hud-chrome-*` 再跑。别花时间怀疑被测代码 —— 先看 `pgrep -fl 'remote-debugging-port=93'`。
111
138
  ⚠️ **它们不是离线门禁**:要真浏览器 + 本机跑着 `dsh web`,所以 `npm test` 与 CI 里**没有**它们(CI 只对 `tools/*.mjs` 做 `node --check`)。`check-panel.mjs` 挡的就是"三套件全绿、浏览器里插件激活或渲染失败"那类事故,所以它跑不过就别提交。
112
139
 
113
140
  ---
@@ -118,8 +145,8 @@ git worktree remove .worktrees/i18n # 收工;分支若不要
118
145
 
119
146
  ```sh
120
147
  node tests/attribution.mjs # 11 条:客户端脚本 URL 形状 → 包名 / bundle key
121
- node tests/logic.mjs # 408 条:从 client.js 抽真函数进沙箱 + 静态检查 + 长度预算
122
- node tests/host.mjs # 39 条:用假 ctx 驱动真实 index.js(路由 / 事件循环 / confirm 安全线)
148
+ node tests/logic.mjs # 554 条:从 client.js 抽真函数进沙箱 + 静态检查 + 长度预算
149
+ node tests/host.mjs # 41 条:用假 ctx 驱动真实 index.js(路由 / 事件循环 / confirm 安全线)
123
150
  ```
124
151
 
125
152
  一条命令跑完:`npm test`。线上(先按 §2.2 换 cookie):
@@ -134,6 +161,7 @@ DSH_COOKIE=… node tests/live-host.mjs # 真实路由;退出码 3 = 宿
134
161
  - **测线上那份代码**:`logic.mjs` 用 `new Function(source + 'return {…}')` 把 `client.js` 里的真函数抽进最小环境,新增函数想被测就加进 `pieces` 列表 + 在桩里补它用到的键。**不要另写一份等价实现来测。**
135
162
  - ⚠️ **`logic.mjs` 里有三个沙箱**(纯函数盒 / 导出盒 `newPieces` / 守夜盒 `watchBox`),各有自己的 `pieces` 列表与桩。往 `client.js` 加一个函数或常量之后,要把它加进**每一个用到它的盒子** —— 漏一个不会立刻报错,而是等那个盒子跑起来才 `ReferenceError`(实测:`WATCH_EVENT_MS` 只在导出盒里被 `snapshot()` 引用,守夜盒里反而先绿;`pruneIncidents` 漏在守夜盒里时,前面的断言全过、最后一个盒子才炸)。守夜盒的 `state` 是**手写桩**,新状态位还要在桩里补上。
136
163
  - **口径有歧义就加断言,不要加注释**:注释不能防回归,断言能。
164
+ - **词典两条断言是一对(2026-10-13 补)**:死键那条管"词典里没人用",**反向那条**管"用了但词典里没有" —— 后者更隐蔽:`translate` 会退回 key 本身,面板 / 导出件上**直接印出** `exportPreviewCopy` / `viewIncident` 这种内部键名,而三套件与死键断言都不响(实测一次踩了 8 个)。加文案时两条都要绿。
137
165
  - **静态检查必须有牙齿**:`logic.mjs` 顶部两条(① `h(Uppercase…)` 引用的组件必须声明过;② 面板渲染区里的裸函数调用必须声明过)是"整段替换吞掉相邻声明"事故的回归测试(§8 第 7 条);验证它的办法是把声明删掉看它是否报出名字。
138
166
  - **宿主半可离线真跑**:`tests/host.mjs` 用假 `ctx`(含 `connection.fetch.register` / `pluginManager` / `ctx.on`)驱动真实 `index.js`,断言路由、事件循环升温、confirm 安全线。
139
167
  - 断言总数写进 [README.md](README.md) 与 [REVIEW.md](REVIEW.md),**改数字要同步改文档**;CI 里有一条门禁专门核这个。
@@ -173,13 +201,17 @@ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolatio
173
201
  - **标签不收缩、数值换行**:行布局是 `label { flex: 0 0 auto; nowrap }` + `value { flex: 1 1 auto; textAlign: right }`;反过来会把标签挤成"一字一行"(实测「宿主事件到达」竖排过)。
174
202
  - **长文本用块式 + 主动断行**:可能超一行的行(概览、折叠摘要)用"标签独占一行 + 值左对齐 + `white-space: pre-line` 自己给 `\n`",不要赌浏览器宽度(曾断在 `最多` 后面,把 `ChatView 22×` 甩到下一行)。
175
203
  - **能不说就不说**:「最多」与「最差那条」名字相同时整行不显示(它只是把同一句话说两遍);说明文字放 `title` 悬停,不占正文。概览两行同理:`overviewLine1()` **已删掉 `(最差那条:未归因)` 后缀** —— 它与紧接着的 `overviewLine2()` 里 `最差那条:<归属>` 的**触发条件完全相同**(都是 `primaryBlame(worst) === null`),相邻两行必然把同一句话说两遍;现在第一行只给 `帧 Xms · LT Yms · 命中 a/b`,「最差那条是谁」只由第二行给,两个口径本该分开读的提醒由第二行自己的标签承担(断言:第一行不含 `未归因`、第二行含)。「最差事件」块同理:`eventValueText(row)` 一行说清三种状态(未开录 → `未开录`;没有事故 → `还没抓到(≥100ms)` —— 阈值写在这一行里,不再另起一行重复;有事故 → `严重/警告 326ms`),`watchFlagText()` 只在"读回 / 裁掉 / 落盘失败"这类**还有话要说**时才返回第二行,未开录与"还没抓到"都不重复。两个一次性说明(`state.copyNote` / `state.pickNote`)由 `resetLayout()` 一并清掉 —— 布局都回到默认了还挂着旧说明,只会让人以为又出错了。
176
- - **长度预算**:概览两行与**四个**折叠组摘要都有字符上限(36 / 60 / **26** / 30 / 34 / 24,顺序 = 概览两行 + 取证明细 / DOM 与堆 / 宿主与链路 / 工具箱),由 `tests/logic.mjs` 钉住 —— 本会话看不到浏览器渲染,只能靠预算把"又挤成三行"这类回归钉在测试里。改文案必须重跑。**这份清单以断言为准**:AGENTS 里这行曾写成 34(记的是另一组数字),对不上时改文档,不要改断言。⚠️ **第五组「包 × 来源 · 窗口」的摘要(`crossSummary`)没有预算断言** —— 组数从 4 变 5 时漏掉了它;它的名字与来源都可能很长(`dsh-client-ui-renderer · DOMWebSocket.onmessage 1234ms` 实测 **53** 字符,在 396px 里会折行),这是已知的债(见 [REVIEW.md](REVIEW.md) §3.4)。补断言时要拿**真实量级**的名字(见 §8 第 13 条)。
204
+ - **长度预算**:概览两行与**六个**折叠组摘要都有字符上限(36 / 60 / **26** / 30 / 34 / 24 / **40** / **30**,顺序 = 概览两行 + 取证明细 / DOM 与堆 / 宿主与链路 / 工具箱 / **包 × 来源** / **技术细节**),由 `tests/logic.mjs` 钉住 —— 本会话看不到浏览器渲染,只能靠预算把"又挤成三行"这类回归钉在测试里。改文案必须重跑。**这份清单以断言为准**:AGENTS 里这行曾写成 34(记的是另一组数字),对不上时改文档,不要改断言。第五组「包 × 来源 · 窗口」的摘要(`crossSummary`)从"没有预算"补上了 —— 它的名字与来源都可能很长(`dsh-client-ui-renderer · DOMWebSocket.onmessage 1234ms` 实测 **53** 字符,在 396px 里会折行),现在用 `clipName()` 把两个名字各自截到 `CROSS_SUMMARY_NAME_MAX` / `CROSS_SUMMARY_SRC_MAX`(14 / 15,完整名字在展开后的交叉表里)。补预算时要拿**真实量级**的名字(见 §8 第 13 条)。
205
+ - **标题栏是**一行永不换行**的工具条:显式左右两组,只有文字可以让位**。`S.head` 不带 `flexWrap`,分 `headLeft`(可压缩:`minWidth: 0` + `overflow: hidden`,里面是标题 / 返回键 / 场景标签 / fps)与 `headRight`(控件,`flex: 0 0 auto`:设置 / 收起 / 关闭)。来历是一次真事故:工具条里每个元素都是 `flex: 0 0 auto`(上面「标签不收缩」那条本来是给**数据行**写的),总宽一超,浏览器就按 DOM 顺序把**尾部三个控件整组**丢到第二行、还左对齐 —— 看着像下面那一块的动作按钮;主界面一行放得下、卡顿详情放不下,于是"两个头部的按钮位置关系不一样"。分组也**不许**再用某个元素上的 `margin-left: auto`(那个技巧只在它所在的那一行生效,一换行就散)。三条实测出来的硬性:① 文字优先级是**标题 > 场景标签 > 其余**,场景用 `flex: 0 100 auto` 先让位(「空闲…」还读得出要紧的那个词,「开发者详…」等于没写);② **亚像素收缩就足以触发省略号** —— zh 详情页原来只差 0.17px,所以头部间距用 6、头部按钮 `padding: 2px 6px`(`S.headBtn`,只作用于这一行);③ **新增头部控件前先量**:头部预算现在是 4 个控件 + 标题 / 场景 / `←`,「深录」因此从标题栏移到了开发者详情的标签页行(它是唯一"只在某一屏出现"的头部控件,却占着最挤那一行 —— 实测把该屏标题挤到 21px)。`← 返回` 缩成 `←`(悬停给全文)也是这笔账的结果。
177
206
  - **单位一致性**:同一张表里不许一处给 `/s`、另一处给累计;任何"和邻居不同单位"的读数都要有断言(曾出现"上面是 /s、下面给累计")。
178
- - **折叠组默认不做昂贵分析,现在由 `group()` 的 thunk 保证**:`group(key, label, summary, ...children)` 支持"唯一一个 child 且是个函数"的形式,折叠时**连函数体都不进**;五个调用点(`detail` / `cross` / `dom` / `host` / `tools`)全传 thunk,所以「取证明细」收起时脚本按包 / 交叉表 / 帧条一次都不算。代价要记住:`cross` 那一层仍嵌套在「取证明细」的 thunk 里 —— 一展开「取证明细」,调用方就先构造 `crossTable(scriptPkgTally())` 与 `kids` 数组(4×5 格,代价低),所以窗口聚合那份缓存不能省。
207
+ - **折叠组默认不做昂贵分析,现在由 `group()` 的 thunk 保证**:`group(key, label, summary, ...children)` 支持"唯一一个 child 且是个函数"的形式,折叠时**连函数体都不进**;六个调用点(`detail` / `cross` / `dom` / `host` / `tools` / `tech`)全传 thunk,所以「取证明细」与「技术细节」收起时脚本按包 / 交叉表 / 帧条一次都不算。代价要记住:**`cross` 嵌在「取证明细」的 thunk 里,「技术细节」又嵌着「取证明细」那条链上的同一批块** —— 一展开任一层,调用方就先构造 `crossTable(scriptPkgTally())` 与 `kids` 数组(4×5 格,代价低),所以窗口聚合那份缓存不能省。摘要那一行(`techSummary`)即使收起也会被求值,所以它**只读已经算好的 `detail`**,不重新采、不重新解析。
179
208
  - **可点 / 不可点的界线(不要做假按钮)**:可点 = 「按来源 / 按包」小表的**具名行**(→ `copyText` 复制「名字 + 毫秒」,成功/失败都照实写进 `state.copyNote`)、工具箱事故清单行(→ 选中)、帧条**有 LoAF 的格子**(→ 钉住那一帧)、组头(→ 折叠)、**可疑脚本的每一行**(→ 复制那条证据行);**不可点** = 堆叠条的**色段**本身(`cursor` 是 `default`)、「其余」那一行(它是合计数,没有单个名字可复制)。
180
209
  - **按钮按需出现,不留"点了没反应"的动作**:「全部折叠」只在**有组开着**时渲染(`anyGroupOpen()`),「恢复默认布局」只在 `layoutDirty()` 为真时渲染(有组开着 / 翻帧被钉住 / 手选了事故 / `scope` 不是 `worst` / 挂着一次性说明);两者都无意义时整行不渲染。新加"布局类"按钮前先问:默认状态下点它会发生什么?什么都不会发生就别显示。
181
- - **i18n 词典成对改**:`DICT.zh` 与 `DICT.en` 同时加键(现在中英各 **239** 键)—— 两条断言钉住这件事(logic.mjs):**键集合必须一致**、**每个键都得有人引用**(撤下界面时留下的死键会被报出来;死键那条只认字面量 `t('…')` / `translate('…')`,真要加动态取键得先放宽它)。**兜底策略跟文档语言**:locale 服务不在时按 `document.documentElement.lang`(取不到退 `navigator.language`)选那本字典,不是中文就用英文 —— 写死中文会让英文用户看到中文,简单反转(`DICT.en ?? key`)又会让中文用户看到英文,两种都错。
210
+ - **i18n 词典成对改**:`DICT.zh` 与 `DICT.en` 同时加键(现在中英各 **405** 键)—— 两条断言钉住这件事(logic.mjs):**键集合必须一致**、**每个键都得有人引用**(撤下界面时留下的死键会被报出来;死键那条只认字面量 `t('…')` / `translate('…')`,真要加动态取键得先放宽它)。**兜底策略跟文档语言**:locale 服务不在时按 `document.documentElement.lang`(取不到退 `navigator.language`)选那本字典,不是中文就用英文 —— 写死中文会让英文用户看到中文,简单反转(`DICT.en ?? key`)又会让中文用户看到英文,两种都错。
182
211
  - **插桩必须可还原**:包装 `setTimeout/setInterval/rAF` 与 `appendChild/insertBefore/setAttribute` 时,保存原生引用、只在"当前仍是我们那个 wrapper"时才还原(避免踩掉别人的包装)。
212
+ - **异步读取统一走 `fetchTextWithin` / `fetchJsonWithin`**(各自有命名 deadline;`stop()` abort 当前代;失败只写 `timeout` / `cancelled`)。源码里的 `fetch(` 必须带 `signal:`,有静态断言;不许再出现裸 fetch。
213
+ - **缓存有上限**:sourcemap 分段表只留最近 `MAP_CACHE_MAX`(8)个 rev,写满配额时先淘汰可重建的 map;内存 `bundleMaps` 同样走 LRU,别让一次长会话把旧 rev 全留住。
214
+ - **快捷键只走 `ctx.shortcuts` 可选注入**:macOS / Windows 绑 `Cmd/Ctrl+Shift+U`;Linux Web 的平台白名单不允许这个组合,不硬塞默认绑定。收起/展开**不抢焦点** —— 外壳在键盘命令之后会把焦点还给输入框,跟它抢只会让用户丢焦点。
183
215
  - **改 `client.js` 一律用带锚点的小批次**:一次替换一个可唯一匹配的片段,失败即整批作废。本项目有**两次**"整段替换吞掉相邻声明"的事故(§8 第 7 条)。
184
216
 
185
217
  ---
@@ -235,16 +267,19 @@ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolatio
235
267
  完整表见 [README.md](README.md) 的「面板怎么读」;开发时最容易搞错的几条(面板 hover 里也有同一份):
236
268
 
237
269
  - **场景标签**(标题栏 `流式` / `空闲 Ns`)是解读所有数字的前提;空闲基线要 60 秒净增为 0 才算真的空闲。
270
+ - **发生频次与留存份数是两套数据(2026-10-12 起)**:`state.watch.captured/coalesced/dropped/trimmedByBytes/deduped` 是**本次会话**的计数器(刷新即归零,只进导出件的 `context.store` / `watch`);`state.tally` 是**跨刷新**的时间桶(`LS_TALLY` = `dsh-perf-hud:tally:v1`,分钟桶 `TALLY_MINUTES`=1440 ≈ 24h + 日汇总 `TALLY_DAYS`=30,桶是 `TALLY_FIELDS` 那 7 元数组)。口径只有一个:**检测到 = triggered**(一次卡顿一串触发只算一次;被 `triggerCapture` 并进 pending、或落在 `coveredUntil` 里的触发**不算新的一次**)/ **coalesced 是同串密度,不是次数**(把两者相加会把一次停顿记成十几次,实测踩过)/ **已保存现场 = `incidents.length`** / **丢失 = (triggered − captured) + dropped + trimmed + failed**。为什么必须两套:留存清单受容量限制(最差 10 + 每天 1),拿它回答"最近一小时几次"会系统性少报 —— 被挤掉的卡顿不是没发生。三条不许越界:① 折叠药丸那一格说的是**检测到**(24h、跨刷新),不是留存份数;② `clearIncidents()` **不清**计数桶(清掉的是证据,不是事实),要清频次得显式 `clearTally()`;③ 窗口边界落在**分钟桶**上,`tallyWindow()` 把 `coveredFromMs/coveredToMs` 一起给出来,写范围时用它,不许吹成"正好 24 小时"。落盘走 2 秒合并定时器(`markTallyDirty`):一次卡顿里 `coalesced` 每帧都会加,不能每帧写盘;读回时形状不对的桶**整桶丢掉**并把原因记进 `tally.loadError`,不静默当成 0。
271
+ - **能力矩阵与诊断包各只有一处来源(2026-10-12 起)**:`capabilities()` 是"哪一层采到了 / 哪一层没有 / 为什么 / 属于哪一档出处"的**唯一事实来源**(叶子形如 `{ state, provenance, reason }`,state ∈ on/degraded/off/unsupported,provenance ∈ measured/inferred/unknown);事故包那串 `missing:` / `howToAdd` **由 `capabilityMissing(row)` 从它推出来**,不再是手写的一串 `if` —— 但每一行的 `前缀:` 形状必须保留(线上与离线都按前缀断言)。`causeCandidates(row)` 是"可能原因"的**唯一生成处**(面板与诊断包读同一份),三条不许越界:① 每条候选都带 `limits`,而且限制里一定有那句"不等于已定位根因";② `worstFrameJs === 'none'` 时不给节流结论,只有 `summary.suspectedThrottle` 成立才写成"更像…";③ `basis` 全部来自已采到的字段。`buildPack` / `manifestFor` / `diagnosticSummary` / `packPreview` 是**纯函数**(不碰 DOM),所以离线套件能直接断言格式 —— 摘要按固定五段(摘要 / 假设 / 缺失 / 下一步 / 元信息),`incident.json` 与面板「导出事故包」同源,包内不含聊天正文 / 提示词 / Cookie / Token / 请求头(本来就没采,不是筛掉的)。⚠️ 加正文文案时注意:摘要里**借用**的 `missing` 行本身带 markdown 记号(那是给 .md 读的),而**面板正文**仍不许带(有断言钉住 `VISIBLE_PLAIN_KEYS`)。
272
+ - **界面是四个视图 + 开发者详情五标签页(方案 1,2026-10-12)**:`state.view` ∈ `main` / `incident` / `dev` / `settings`,`state.devTab` ∈ `frames` / `dom` / `host` / `tools` / `raw`,落盘只存这三个字段(`LS_VIEW`),**不存任何读数**。三条硬性:① **主界面只读纯函数**(`healthVerdict` / `causeCandidates` / `trendSummary` / `selectedIncident`),界面自己不做推断 —— 与 `detailContext()` 同一条规矩;② **视图只在被选中时构造**(`view === 'incident' ? incidentDetail(detailContext()) : null`):`incidentDetail` 要求"现在真的在历史上下文里",无条件先算一遍会在实时上下文里抛错(实测:面板整块被外壳停用,而三套件全绿);③ **监测不可用不许伪装成正常**:HUD 关着 / LoAF 没装上时主界面给"监测受限" + 原因,阈值与判色共用 `frameLevel()`。开发者详情的五个标签页**照实映射现有分组**(帧与归因 = 概览 + 最差事件 + 回调归因 + 取证明细;DOM 与堆;宿主与链路;工具箱;原始数据 = 导出与存储键位),不硬套概念图的名字。折叠药丸展开时**回到主界面**(普通用户点开不该看到一屏技术数据)。**卡顿详情那一屏要按"普通用户也会走到"来排**:常显只有结论层(上下文标题 / 复制摘要 / 导出事故包 / 回放范围 / 归因状态 / 历史帧时间轴),技术读数全在 `tech`(「技术细节」)里默认收起,而那一组与开发者详情**共用同一份渲染**(`techGroup` → 环 / 脚本拆解 / long task / 可疑脚本 / 交叉表 / 口径)—— 以前同一份 `incidentDetail` 在开发者详情里是可折叠的、在普通用户那一屏却是**全平铺**的,方向正好反了。另两条同源收敛:① 一屏只有一个返回键(标题栏;视图外那一行动作已删);② 事故 id 与"这一帧多少 ms"各只印一次(上下文标题),时间轴卡里不重复。
238
273
  - **命中 a/b** = 四条腿任一给出名字的长任务数 / 长任务总数。**它会被 DOM 腿抬高**(流式时写入窗口内几乎必然有写入),必须配合「归属 Top」分布读。
239
274
  - **帧内未归因**(2026-10-07 起在「取证明细」组里,不在常显):`idle = 帧间隔 − 插桩回调耗时`,所以**没插桩时它退化成平均帧间隔**(实测 16.7ms 被当成未归因)—— 那时面板直接写「要深录」,连"该帧几乎无 JS 解释"的结论也不报(`unexplainedFrame` 先要求 `state.deep === true`;不要求的话这条结论在**每一条**长帧上都成立,等于恒报)。插桩后:峰值 ≈ 最差帧(≥95%)时给结论,并提示用 Rendering 面板的 Paint flashing 交叉验证。**CSS 动画只在这里现形**(它不经 JS 调度器)。
240
275
  - **帧严重度只有一个口径:`frameSeverity(row) = max(blocking, 帧时长)`** —— 判据、排序、标签**同一个函数**(以前 `worstLoaf` / `loafRanks` 只看 `blocking`、`pickList` 看 `max`,于是"自动选中的那条"可能不在清单里,标签显示 `帧 0/N`)。⚠️ **不能只看 `blocking`**:实测一次 45 帧的会话里 `blocking` **全部 < 100ms**(最大值 92ms),只看它会让清单退回"全部",而"帧时长"才是面板自己的严重度口径(判色 120/200ms、圆环上 `帧里花在哪` 也是它)。
241
276
  - **翻帧清单只收达线的帧(`pickList`)**:判据是 `frameSeverity(row) ≥ PICK_MIN_MS`(取 `WATCH_TRIGGER_MS` = 100ms)。**标签显示的是帧时长**(与圆环同一个数,`blocking` 放在 tooltip 里),否则会出现"清单里出现 92ms"这种自相矛盾的读数。**一条都不到线时退回全部**(`filtered:false`,标签上不显示 `≥100ms`),否则按钮点了像坏的。
242
277
  - **看的是哪一帧(`pickedLoaf`)**:面板默认看 `worstLoaf()`(最近 `WATCH_LOAF_MAX` 条里 `frameSeverity` 最大的那条)—— **它不是按时间刷新的,而是被更差的一条顶掉才换**,所以同一条常常挂很久。上面那行 `帧 N/M ◀ ▶ ⟲` 让人翻别的帧:`loafRanks()` 按 `frameSeverity` 降序出清单,`pickStep(±1)` 走一步(到头停住),`pickAuto()` 清掉选择回到自动。⚠️ 存的是**选中的 `t` 而不是下标** —— 帧环会追加新的、挤掉老的,下标会悄悄指到另一条帧上;选中的那条一旦不在环里(`pickedLoaf` 查不到 `t`),就清掉选择退回自动,不指着一个不存在的帧。翻帧会写 `state.watch.pickT` 并 `bump()` 重绘;环与「本帧」那组都吃 `pickedLoaf()`,所以它们永远说的是同一条帧。⚠️ **`t` 是 `entry.startTime`(页面时间轴),所以任何跨度的上界都是"页面已打开多久"** —— 跨度不该超过它;超过了就说明环里混进了旧页面的条目。清帧环只有两条路:**硬刷新**(环只在内存),或 `__perfHud.clear()`(它**也**清 `watch.loaf`,`clearIncidents()` 只清事故留存 —— 二者刻意分开)。
243
278
  - **事故选中的是「事故 id」不是下标(`state.watch.selId`)**:新事故按严重度重排,存下标会悄悄指到别的条目上。`selectedIncident()` 返回 `{ row, auto }`:默认自动跟 `incidents[0]`(留存清单按严重度降序),用户点过就跟用户选的那条,**选中的一旦被挤出留存清单就退回自动**。三处共享同一份上下文:首屏「最差事件」块、工具箱清单的 `●` 标记、`watchSelectedText()`。`selectIncident(id)` 只选中;`revealIncident(id)` = 选中 + 展开「取证明细」+ 把翻帧钉到最近那条 LoAF。
244
- - **事故 ⇒ 帧只是「就近定位」,且有距离上限(`frameNearestIncident(row)`)**:取帧环里 `t` 离 `row.atPerfMs` 最近、**且落在 `row.window.fromMs..toMs` 内**的那条 LoAF。为什么必须有上限:跨会话读回的事故(perf 时间轴已重开)离当前帧环可能差几百秒,那时"最近的帧"是一条**无关帧**。定位不到就写 `state.pickNote` 留一句如实说明 —— 不允许"按钮点了像没反应",也不允许假装取证明细里那条就是它。**事故切片里的 `frames` 与面板那个帧环不是同一份数据**,这一层只做就近。
279
+ - **事故 ⇒ 帧不再「就近定位」(2026-10-11 删掉 `frameNearestIncident`)**:以前是"在实时帧环里找一条离事故最近、且落在窗口内的帧" —— 跨会话读回时它只能给 `null`,而且会把一条**无关的实时帧**当成这条事故的证据(正是"上下文混用"的另一半)。现在历史帧直接来自事故包自己的 `incident.frames`(见上面那条 bullet),**"定位不到"这件事在结构上消失了**。`state.pickNote` 只在"老包里一条帧记录都没有"时留一句如实说明 —— 不允许"按钮点了像没反应",但也不假装有帧可看。
245
280
  - **严重度必须「文字 + 颜色」两样给**:`severityLevel(ms)` → `bad`(≥200ms,与判色红档同一条线)/ `warn`(≥100ms,守夜触发线)/ `ok` / `none`;配 `severityText` + `severityColor` 一起用。只给颜色在灰度截图 / 色盲下等于丢信息。
246
281
  - **宿主 event-loop 那一行给 p50 / p99 / max 三个数**(宿主半每秒采三个数下发;README 早就写了 max,代码这次才跟上)。老版本宿主半没有 `max` 字段时**如实只给 p50/p99**,不补 0 —— 只给 p99 会把偶发的秒级停顿藏掉。
247
- - **可疑脚本(证据 → 代码,「取证明细」里的最后一块)**:把「**事故 → 帧 → 来源 → 包 → 文件:行**」落到**个体脚本**上 —— 上面几块回答"时间花在哪个包、哪类调用"(排行),这一块回答"打开哪个文件、哪个函数"(定位)。最多 `EVIDENCE_MAX = 4` 行(按耗时降序),范围写在标题上:**事故优先**(`可疑脚本 · 事故 i84 · 窗口内`,事故的脚本是窗口切片、最多 5 条、**跨会话读回仍在**),没有事故脚本时才退回 `可疑脚本 · 本帧`;每行 `<函数名> @ <包>` + 毫秒,悬停给完整证据行,**点击复制**那条证据行;底下一行给**分母**(`4/11条脚本 · 定到包 1/11`)+ 口径 + 缺什么;一条名字都没拿到时收敛成一行 `可疑脚本:0 条定到名(窗口内 11条脚本)· 要深录`(不摆一列"函数名未记录 @ 包未定名")。
282
+ - **可疑脚本(证据 → 代码,「取证明细」里的最后一块)**:把「**事故 → 帧 → 来源 → 包 → 文件:行**」落到**个体脚本**上 —— 上面几块回答"时间花在哪个包、哪类调用"(排行),这一块回答"打开哪个文件、哪个函数"(定位)。最多 `EVIDENCE_MAX = 4` 行(按耗时降序),范围写在标题上,**范围随上下文走**(`evidenceScope`):历史事故里优先给**选中那一帧自己**的脚本(`可疑脚本 · 事故 i12 · 历史帧`;脚本与定位在捕获时存下来,**跨会话读回仍在**),这一帧没有记录时才退回 `可疑脚本 · 事故 i12 · 窗口内`(块首写明"不是这一帧的",`frameEvidenceMissing`);实时上下文才给 `可疑脚本 · 本帧`。**绝不从另一个上下文顶上**;每行 `<函数名> @ <包>` + 毫秒,悬停给完整证据行,**点击复制**那条证据行;底下一行**只给分母 + 一句悬停提示**(`4/11条脚本 · 定到包 1/11 · 悬停看口径与缺口`),口径与缺口全文进 `title`(`evidenceFooterTitle`,与「复制摘要」共用同一份纯文本措辞)。⚠️ **面板是纯文本渲染,不是 markdown**:`**` 与反引号摊在正文里只会变成噪声(实测被吐槽"这段文字很丑陋"),所以**会显示在正文里的文案,词典值不许出现 markdown 记号** —— 有一条断言逐个键核这件事(`VISIBLE_PLAIN_KEYS`),新加正文文案时先看它。一条名字都没拿到时收敛成一行 `可疑脚本:0 条定到名(窗口内 11条脚本)· 要深录`(不摆一列"函数名未记录 @ 包未定名"),同样悬停给口径与缺口。
248
283
  **口径必须按三档读**(这也是这块存在的意义):
249
284
  ① **直接测量** = 函数名(`sourceFunctionName`)与毫秒(`duration`)是 LoAF 给的;
250
285
  ② **归因推断** = 包名 / 文件路径 / 行号由 URL 与 sourcemap 分段表推出来(`viaText` 压成五档:直接读出来 / 分段推断 / 不是插件脚本 / 解析失败 / 还没到位;原始 via 码进悬停与导出件);
@@ -254,7 +289,12 @@ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolatio
254
289
  **事故那一侧优先用"存下来的定位"**(`incidentScriptEvidence` + `incidentEvidenceAt`):包 / 文件 / 行号在**捕获那一刻**就写进了 `summary.topScripts[]`,面板与「复制摘要」都读这份,只有老包缺字段时才回退现算。为什么必须这样:现算依赖**当前会话**的 `state.precise` 与内存里的脚本文本 / 分段缓存 —— **刷新之后两者都空**,现算会把存好的 pkg/file/line 变成 `null` / `还没预取`,历史事故的定位就丢了(实测:深录档抓一次 → 关掉深录再刷新 → 证据行与刷新前**逐字一致**)。两条路共用同一个函数,所以不会出现"面板上有、复制出来没有"。
255
290
  **文件与行号是两个坐标系,不许拼**:`lineScope` 分三档 —— `file`(单包 URL / 单包组合:行号就是那个文件里的行,照旧 `文件:行`)/ `bundle`(合并批:行号是**合并后大文件**里的行,而文件只回答"这段代码属于哪个包的那个文件" → **只给文件**,行号单独写成「合并脚本行 N」)/ `null`(没拿到)。老包没有 `lineScope` 时按 `pkgVia === 'section'` 现推。
256
291
  数据来源与导出:`summary.topScripts[]` 多带 `invoker` / `file` / `line` / `lineVia` / `lineScope`(`file` 取自 section 的 `sources`;`paths` 是后加字段,**老 localStorage 缓存没有它 → 文件留空,不为它重下那份 12MB 的 map**);`复制摘要` 也带上证据行 + 「窗口内 ≥100ms 的交互」(端到端,只进交接件)+ 缺什么,目的是**能直接交给另一个 AI 接着查**。
257
- - **事故回放(「取证明细」里,紧跟实时帧条)**:把**选中的那条事故**的那 13 秒窗口画出来 —— 数据**全部来自事故包**(`incident.frames` / `incident.loaf` / `summary`),所以**刷新生效、零新增采集**;面板上面那条 60 秒帧条是**实时**帧环(刷新就没了),两者刻意分开、各自写明范围。横轴**按时间**放(`replayX`):整段 rAF 被挂起的那几秒**留白**,一眼看出"这段时间根本没在测",按序号放会把它压成一根柱子 = 说谎;档位用实时帧条**同一个** `sparkLevel`、颜色共用 `SPARK_COLORS`(两处读数不许打架)。底下三行:**范围**(跨度 / 帧数 / p50 / p99 / 最差帧 / 缺口数)、**最差帧的时间去向**(复用 `frameSplit` 的四段分区,相加 = 这一帧时长)、**口径脚注**(这是捕获时那 13 秒,不代表现在)。⚠️ **不排帧内执行顺序** —— LoAF 不给每帧内脚本的起止时间;逐帧明细仍走「导出事故包」+ `tools/inspect-incident.mjs`。
292
+ - **诊断上下文:取证明细一次只服务一个上下文(2026-10-11 起,本次改版的核心)** —— `live`=实时帧环里的当前帧,`incident`=选中事故里的一条**历史帧**;由 `state.watch.mode` 决定,**分流只发生在 `detailContext()` 一处**,渲染那侧只读它的返回值(有断言钉住"历史那一支不出现 `pickedLoaf` / `scriptPkgTally` / `frameRing`")。为什么必须这样:以前「取证明细」里帧条/翻帧/饼图吃实时 `pickedLoaf()`,而事故卡片与回放吃 `selectedIncident()`,于是"上面是历史事故、下面是另一条实时帧的编号与饼图"必然出现 —— 实时与历史可以**共用视觉组件**(`ringBlock` / `scriptBreakdownRows` / `evidenceBlock` / `crossBlock`),但**不许共用一个错误的上下文**。上下文落盘在 `LS_WATCHUI`(只存 `mode` / `selId` / `frameT` / `scope` / `onlyBad`,不存数据),读回时 id 或帧 `t` 找不到就退回自动、没有事故就退回实时;所以**刷新之后仍能打开同一条事故的同一帧**(`tools/check-incident.mjs` 真页面验过)。实时那一侧的 `pickT` 在切换上下文时**不动**,切回来还是原来那条。
293
+ - **历史帧的逐帧回看(「取证明细」的历史事故那一支)**:帧来自**事故包自己**(`incident.frames`),不再从实时帧环里"就近定位"(`frameNearestIncident` 已删 —— 那个启发式在跨会话读回时只能给 null,还会把无关的实时帧当成这条事故的证据)。`gap === true` 不是帧(整段 rAF 被挂起):不参与序号、选中与翻帧,只在帧条上留白。帧**存 `t` 不存下标**(与实时翻帧同一条规矩);默认选中最差那一帧(严重度按**帧间隔**,与事故严重度同一口径)。导航只有**两个箭头 + 一个过滤器**:◀ 上一帧 / 下一帧 ▶ 按时间序(与帧条方向一致),**「只看异常」开着时步长落在帧间隔 ≥ 守夜触发线(`PICK_MIN_MS`)的帧上**(判据只有帧间隔,不混 LoAF 的 `blocking` —— 实时那一侧的清单用的是 `max(blocking, LoAF 时长)`,两个上下文各自在自己那份数据上有定义,刻意不互相借用);`incidentFrameNav(row, frame, onlyBad)` 与 `incidentFrameStep(±1)` **共用 `incidentFrameHot` 这一个判据**,所以"按钮亮着"与"按下去会到哪一条"永远是同一件事(以前是四个按钮:两对箭头做同一件事、只是步长不同,读的人得先弄清"异常帧"是什么才敢按);没有下一条就**禁用**按钮,不留"点了没反应"(⚠️ React 18 的状态更新排在微任务里,验禁用态必须**分两次 evaluate** 读 DOM,同一次同步执行里读到的还是旧 DOM —— 验收器踩过这个坑)。
294
+ - **历史帧条是导航器,不是装饰图(`replayColumns` / `replayX` / `replayCellTitle`)**:事故窗口按**时间**切成 `REPLAY_COLS`(240)列,每列取该列里**最差的那一帧**(尖刺不会被平均掉),并把**那一帧的引用**留在格子里 —— 点击 / 选中 / 高亮全部映射回 `incident.frames` 里真实存在的记录,不合成帧(有断言:每个非空格子的 `frame` 都在原数组里)。整段 rAF 被挂起 / 采样缺失的地方**留白**(那一列没有可点的帧),不补桩 —— 按序号放会把缺口压成一根柱子 = 说谎。档位用实时帧条**同一个** `sparkLevel`、颜色共用 `SPARK_COLORS`;选中帧是**一条蓝色竖线**(`--dsw-alias-state-business-primary`),柱子的颜色仍然表达它自己的档位。240 列是绘制量上限(一列一个 DOM 节点),900 帧的窗口也不会把面板压垮(有断言)。**代价要记住**:同一列里更轻的那些帧**点不到**(它们仍是真实记录,用 ◀ ▶ 能走到)—— 想"任意帧都能点"就得放弃这个上限,而一条 13 秒窗口在 60fps 下有几百帧,一帧一个节点会把画条子变成面板自己的开销。横轴的墙钟用**事故包自带的 `env.perfOrigin`**(`incidentWallClock`),不是当前页面的 `wallClock()` —— 否则跨会话读回的事故会被算成"今天的某个时刻"。
295
+ - **一帧的分解与证据都只吃那一帧**(`incidentLoafAt` / `incidentFrameTally` / `incidentFrameEvidenceRows` / `incidentLongTasksAt`):与这一帧**时间重叠**的那条 `incident.loaf`(多条取最重的)提供时间分解与脚本证据;这一帧的脚本聚合走 `tallyScripts`(与实时同一套口径),但 `pkg` / `pkgVia` **优先用捕获时存下来的** —— 刷新之后当前会话的 sourcemap 缓存是空的,现算会把历史事故的包名全算成"未定名"。**没有就是没有**:这一帧没有 LoAF 时,「技术细节」里**只写一行** `techFrameNone`("这一帧没有长动画帧记录(只存了帧间隔):没有帧内分解,与窗口内的 long task 也不重叠 —— 不拿别的帧顶上"),而不是让"帧里花在哪""重叠的 long task""可疑脚本"三块各说一遍自己那份缺失(以前一屏上连着四行都在说"没有");收起时那一组摘要也退成 `techFrameNoneShort`(「这一帧没有记录」)。不回退去拿最差那一帧、更不拿实时帧;脚本证据退回**事故窗口内**的切片时,块首会写明"不是这一帧的"(`frameEvidenceMissing`),并且零证据那一行的分母范围按上下文说(历史帧 / 本帧 / 窗口内三档,不许一律写"窗口内")。long task 只给**与这一帧时间重叠**的那几条(窗口里有但不重叠的不算)。
296
+ - **归因状态三档(§五,`incidentVerdict`)**:`confirmed`(有 JS 阻塞证据 **且** 窗口里定到了包)/ `suspected`(有 JS 证据但没定到包;或满足 `suspectedThrottle` 的检测条件)/ `unattributed`(其余)。三条不许越界:① **不**把"没有 JS 证据"说成"浏览器节流 / rAF 被挂起" —— 那只是可能,只有 `summary.suspectedThrottle`(≥500ms + 无 JS 重叠 + 前后帧中位健康)成立时才提,而且写成"更像…";`jsNo` 的文案也改回"无(longtask/LoAF 都不与它重叠)",不再带结论;② **不**把"窗口里耗时最高的包"宣称成根因,已确认那一档自己写着"这只缩小到哪个包,不等于已定位根因";③ 未归因给的是那句校准过的说明("未观察到足以解释该帧的 JS 阻塞证据;请结合前后帧和时间间隙检查 rAF 挂起或浏览器节流")。档位**文字 + 档位色**两样一起给(`verdictColor`),面板与「复制摘要」读同一个函数。
297
+ - **帧条里的「Long Task」与「其余」**:`longTaskBlock` 只列与选中帧重叠的 long task(owner / component / source),`frameLtWindow` 给窗口内总条数做分母;证据块 / 交叉表在两支上下文里共用,只是喂进去的 tally 与范围标签不同(`crossIncidentHint` 说清历史那一支的范围是**整份事故包**)。
258
298
  - **帧里花在哪 / 脚本细拆 / 按来源 / 按包 / 交叉表(都在「取证明细」)**:环**只画四段**(脚本 / 任务里没归到的 / 渲染 / 尾部),它说的是"帧的时间去哪了"这**一个**维度;脚本内部的「来源」细分与「按包」各是一条 **100% 堆叠横条**(`barSegments`:Top3 + 其余;段宽按这一维度的总量算,所以条恒满格;名次靠由深到浅的透明度,与环同一套读法),条上面是**每项一行的小表**(名字左、毫秒右,等宽;超长名字省略号 + title 给全名;`其余` 那行带条数)—— 不要退回"一整块右对齐长文本":那样折行会折在名字中间(实测 `ResizeObserverCallback` / `index-5SrrfWpU.js` 被劈成两半)。每段 hover 给 `名字 毫秒 · 占比`;**条的画法有三条实测出来的硬性**:① 填充**不透明**,名次靠**压暗**(黑色 overlay `.32/.46/.58/.68`)而不是变透明 —— 两个坑都踩过:满不透明 + 近白/纯绿的 token(`rgb(249,250,251)` / `rgb(34,197,94)`)是**发光色块**,读不出占比;改成降不透明度又让整条**发灰**(实测被吐槽「更淡了」)。压亮度两头都不占:色相还在,亮度落回中间调;② 段用 `flexGrow: 毫秒` + `flexBasis: 3px`,小段也占得住 3px(以前 6% 只有 21px × 0.28),段间 1px 缝;③ 顶段 ≥ **80%** 时在条右边内联占比(`86%` / `100%`)—— 比例严重偏斜时"几乎全在一桶"要用数字说出来。条高 8px,轨道(12% 白)始终可见;再往下是**包 × 来源交叉表**(`crossTable`,组标题 `包 × 来源 · 窗口`,`state.groups.cross` **默认收起**;它跟上面「·窗口」那三行吃的是**同一份 `scriptPkgTally()`、同一个范围 —— 累计**,hint 里写明这一点)—— 两个正交维度分开看是两张榜单,交叉起来才回答"哪个包的哪类回调在烧时间":只展开 Top3 × Top3,其余行列用减法补足,四块之和恒等于脚本总耗时(有断言)。三处**悬停联动**(共享 key `ring:` / `src:` / `pkg:`)**整体停用,而且辅助代码已删干净**(`hot` / `setHot` / `hotProps` / `hotStyle` 与各处的 `hot === …` 都不在了)。历史:2026-10-08 真实鼠标移到交叉表行标签上会让外壳把**整个条目停用**(面板消失、`errors` 为空、`window.onerror` 不响、`__perfHud.enabled` 仍是 `true`)—— 它**不经过 `HudBoundary`**(那个边界是 `PerfHud()` 返回的树的一部分,抛点更可能在函数体里),所以连错误原文都拿不到。2026-10-11 复核:**在当前代码上复现不出来**(真实鼠标扫过交叉表内 163 个元素 + 153 次 JS 派发 `mouseover`,全程存活、`errors: 0`、控制台无输出),所以既不能证明根因、也不能证明已修好 —— 按"稳定优先于观感"保持停用。**要重新尝试**:照渲染区那条注释搭探针(遍历 DOM 顺序 + 装 console 钩子,**钩子必须在 reload 之后装**,否则随旧文档一起被清掉),同时 grep 外壳日志里的 `slot entry crashed in …`;复现出来之前不要再往渲染里加 per-element 鼠标状态(有一条静态断言钉住"渲染区不含 `onMouseEnter/Leave`")。**点击(onClick)类交互不受影响,已实测**。颜色跨视图一致:来源一律 `--dsw-alias-brand-primary`、包一律 `--dsw-alias-state-success-primary`。
259
299
  - **环自身的几何**:只用 LoAF 给的三个时间点(`t` / `renderStart` / `styleAndLayoutStart`),**恒定四段**(`frameArcs`:脚本红 / 任务里没归到的中性灰 / 渲染橙 / 尾部蓝)。两条硬性:① **只做分区**,四段之和恒 = `total`;② 环上不出现 CSS:颜色只给名字,由渲染那侧映射 token。**来源细分与包都不进环** —— 它们各有自己的堆叠条(理由见下面那条铁律)。**尺寸是量出来的**:48px 的环周长 126px(1% ≈ 1.3px),段一多就分不出来 → 84px、`r = 34`、`stroke-width 13`、段间 **1.4px 缝**(扣除规则:`raw > 2×缝` 才扣,小段保原长;推进量按原始长度走,缝才落在段与段之间)。**单帧里 >5ms 的脚本通常只有 1~2 条**(实测:一个任务里连着做 timer + 事件 + 微任务,LoAF 仍然只报一条 `setTimeout`),所以"块多不多"由这条帧里有几条脚本决定;真正的多来源在「·窗口」那几行。环**只画 4 段还有尺寸上的理由**:84px 周长 213.6px 里 2% ≈ 4px,段一多就只剩缝;满宽的堆叠条(372px)里 2% = 7.4px,看得见。
260
300
  - **任务那一段再按 `scripts[].duration` 分成"被 LoAF 归到脚本的"与"没归到的"(后者是**差值**:短于上报阈值的脚本 + 事件派发 / 微任务 / GC),脚本那一段再按 `forcedStyleAndLayoutDuration` / `pauseDuration` 分成 纯执行 / 强制样式布局 / 暂停(**三者是 `duration` 的分区,相加 = 脚本耗时**;为 0 的两项不显示,免得把行挤成两行)。**绘制与合成分不开**(页面内没有 API,那一层在缺失声明里),所以最后一段是"剩下的都在这儿",不能读成"样式布局占比"。
@@ -277,6 +317,20 @@ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolatio
277
317
  - **判色阈值同时写进导出 JSON 的 `context.thresholds`**;导出件还带场景 / 空闲秒数 / 精确归因与每个 map 的状态 / 宿主路由与 profile、Node、插件版本 / 实验候选与方法 —— 这是"文件离开现场后仍能自证口径"的设计,改读数口径时**必须同步改它**。
278
318
  - **隔离实验**:所有窗口注入同一份合成负载(每 250ms 燃烧 8ms,走未插桩的 `natives.setInterval`,只烧 CPU 不动 DOM 以免污染归因表);每个候选只跟**紧邻基线**对照(A/B/A),**噪声底** = 基线↔基线漂移的最大绝对值,Δ 小于它一律标「噪声内」;窗口含流式标「不可比」且**不给结论**。它测的是**固定开销**,不是"该插件在某种用法下才贵"。
279
319
 
320
+ ### 7.1 记录器可靠性三条(2026-10-17,来自官方 Inspector 的参考)
321
+
322
+ 1. **异步必须有 deadline 与代际**:所有会 pending 的网络读取都走 `fetchTextWithin` / `fetchJsonWithin`
323
+ (各自有命名上限:宿主 5s、sourcemap / 脚本文本 20s、probe 30s);`stop()` abort 当前代,旧代的结果一律丢弃。
324
+ 失败只允许写成 `timeout` / `cancelled`,不允许永远停在 `text-pending`。静态断言:源码里的 `fetch(` 必须带
325
+ `signal:` 或经上述 helper(`tests/logic.mjs`)。
326
+ 2. **宿主快照四态**:`live` / `warming` / `stale` / `unsupported`。没有样本时 `p50/p99` 不许渲染成 `nullms`,
327
+ `infraVerdict()` 不许在零样本时给「链路已排除」。`hostAt` 超过 2.5 个轮询周期标「Ns 前」并降级判定;
328
+ `eventLoopLive=false` 写"宿主未采到";宿主恢复后事件监听要重试(不能因为第一次 404 就永久 `hostWatchAsked`)。
329
+ 3. **事故窗口要盖完整性章**:LoAF / longtask 条目带 `seq`;窗口落定时算 `expected - kept`,写进
330
+ `incident.integrity = { captureComplete, loafDropped, longTaskDropped, reasons }`。`captureComplete=false` 时
331
+ 事故包 `missing` 加一行、manifest 标 `complete:false`、面板写「现场不完整」。`hiddenGaps`(rAF 挂起)是
332
+ 另一个维度,不混进 `captureComplete`。改这个结构要升 `PACK_SCHEMA_VERSION`。
333
+
280
334
  ---
281
335
 
282
336
  ## 8. 已知坑与已证伪的死路
@@ -303,7 +357,9 @@ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolatio
303
357
  11. **单帧尖刺不能用 p50 基线判,而且基线必须排除本窗口**:真实数据里有一帧 **983.7ms**,而它前后都是 **16.7ms(正好 60fps)**、窗口里没有一条 longtask/LoAF 与它重叠 —— 这一帧恰好卡在 1 秒间隙线**下面**(983.7 < 1000),所以被当成"真卡顿"抓了下来。三条改法:① `baseline` 改成 `frameRing.stats(from, to)`(排除本窗口),否则"比会话最差还差"永远不成立;② 额外交 `worstFrameJs`(最差帧有没有 JS 重叠)与 `suspectedThrottle`(≥500ms + 无 JS + 前后帧中位 <100ms);③ 判"是不是真的变差"要看**最差帧的倍数**,不是 p50 —— 同一份包原先给出"与本会话平时基本一致(×1.00)",把 983ms 的尖刺判成了正常。
304
358
  12. **`PerformanceObserver.observe({ buffered: true })` 会把已经采过的 LoAF 再放一遍**:每次重新挂观察器(`disable→enable`、HMR 重载、重新 `apply`)都会重放最近 200 条 —— 同一个 `startTime` 于是被**重新触发**,同一次卡顿裂成多条不同 id 的事故。实测面板上同时出现 `i2 / i29 / i31`,三条的时间戳一模一样(298ms @ 23:30:18)。修法:去掉 `buffered: true`(挂上之前的帧我们本来也没采);另外按 `at|cause` 去重兜底 —— 但**缺这两个字段的条目不能参与去重**,否则会把真事故当成重复吃掉(单测里踩过)。
305
359
  13. **长度预算的桩数据必须是真实量级**:`detailSummary` 的预算一直是 30~34,而桩里的数字全是 0/个位数,于是"最差秒 152ms · 未归因 17ms · DOM 写入 36250"在 396px 面板里被挤成两行这件事**从来没被断言发现**。现在桩里塞五位数,并把摘要砍到只留结论对。
306
- 14. **批量替换(按次数替换)会改到同名调用点,而且文件本身还是"语法正确"的**:给 `frame()` 的 rAF 循环插一句自计时,锚点 `state.fpsLoop = natives.requestAnimationFrame(frame);` 在 `frame()` 与 `start()` 里**各有一处**,按次数替换就把 `selfStarted` 插进了 `start()` —— 那里没有这个变量,于是**插件激活直接失败**(外壳日志 `web boot: 1 entry did not activate / dsh-perf-hud: failed`),面板整块消失,而 `node --check` 与三套件**全绿**(单测只抽纯函数,不跑 `apply`)。教训:替换后要 `grep -c` 确认命中数;凡是插进"只在浏览器里跑"的路径(apply / start / 渲染),必须真的打开页面看一眼。
360
+ 14. **写模块作用域的视图函数时,`props` 里的字段必须落地成局部名字**:实测把 `mainView(props)` 里写成裸的 `trendMs`(它只是 props 的一个键),浏览器当场 `ReferenceError: trendMs is not defined` → 外壳把整个条目停用(`slot entry crashed in 'shell.overlay'`),面板整块消失;而 `node --check`、三套件、静态检查**全都看不出**来(它是个语法合法的标识符引用,不是"引用了没声明的函数")。→ 抓法只有一条:`tools/check-panel.mjs` 真开一次页面;要看清原文就临时用 CDP 收 `Runtime.consoleAPICalled`(外壳会 `console.error('slot entry crashed in …')`)。同一批还踩到:用 `page.evaluate` 拿整个 `localStorage` 会打出几十万字,别那么写探针。
361
+ 15. **`tools/check-*.mjs` 里的 `check(name, ok, detail)` 只认布尔**:传数组/对象一律算失败(我给设置页那条传了 `[a, b, c]`,于是"三块齐全"明明为真却报 FAIL)。同一个文件里两种签名都出现过,改之前先看它自己的定义。
362
+ 16. **批量替换(按次数替换)会改到同名调用点,而且文件本身还是"语法正确"的**:给 `frame()` 的 rAF 循环插一句自计时,锚点 `state.fpsLoop = natives.requestAnimationFrame(frame);` 在 `frame()` 与 `start()` 里**各有一处**,按次数替换就把 `selfStarted` 插进了 `start()` —— 那里没有这个变量,于是**插件激活直接失败**(外壳日志 `web boot: 1 entry did not activate / dsh-perf-hud: failed`),面板整块消失,而 `node --check` 与三套件**全绿**(单测只抽纯函数,不跑 `apply`)。教训:替换后要 `grep -c` 确认命中数;凡是插进"只在浏览器里跑"的路径(apply / start / 渲染),必须真的打开页面看一眼。
307
363
  15. **改 `client.js` 时不要同时跑"受控实测"**:`dsh-client-hmr` 会热载新代码,正在跑的采集会**换到新实例**(seq 归零、状态重置),实验结果直接作废。实测踩过一次:边写落盘功能边跑受控捕获,拿到的 id 与时间戳全对不上。要采集就先把代码定下来。
308
364
  16. **`node:inspector` 的同进程会话总能连上,而且允许多会话并存**(Node v25.9.0 实测,A5 采宿主 CPU profile 时撞到):`new Session().connect()` 不需要 `--inspect`(连上后 `inspector.url()` 是 `undefined`);**另一个会话正在采时再 `Profiler.start` 也不报错** —— 后来的那个直接接手采样,两个会话各自 `Profiler.stop` 都能拿到 profile。所以「宿主已经开着别的 inspector,所以我们会失败」是**错的**:真正防止两段采样互相踩的是我们自己的单飞(`running` 标志),不是 V8。错误分支实际挡的是:inspector 不可用 / 会话未连接或已断开(`post` 没 connect → `Session is not connected`;同一会话重复 `connect()` → `The inspector session is already connected`)/ `Profiler.*` 自身报错。**不管哪种都要把错误原文回给调用方,不许假装成功。**
309
365
  17. **V8 CPU profile 的采样密度远低于标称**:`Profiler.start` 的默认间隔是 1000µs,但实测只有 **~180~250 次/秒**(V8 只在"可采样点"取栈,原生/GC 期间取不到)。所以一段 **5 秒** `.cpuprofile` 只有 ~1000 个采样、**约 10~13KB**(轻负载 11.5KB / 重负载 12.5KB / 边解析 5.5MB JSON 10.3KB),体积由**不同调用栈的数量**决定、不由时长决定 —— "profile 会很大所以别常开"里的**体积**不是理由,常开的代价在采样本身。反过来:回包体积从来不是问题,别为此加压缩/分片。
@@ -330,21 +386,24 @@ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolatio
330
386
  1. bump `package.json.version`,跑完 §3 的门禁并全绿。
331
387
  2. 打 tag `vX.Y.Z`(**tag 必须与 `version` 一致**),在 GitHub 建 Release。
332
388
  3. `.github/workflows/release.yml` 在该 Release 发布时自动 `npm publish`(用 **npm Trusted Publishing / OIDC**,不配 `NPM_TOKEN`)。它对该版本**幂等**:发布前先 `npm view` 查 registry,查到就跳过发布并写 notice(所以给手工首发那一版建 Release 不会发失败,只会跳过);**查询失败照实报错退出**,不当成"没发过"。
333
- 4. **第一个版本必须手工发**(`npm login` 之后 `npm publish --access public`):OIDC / Trusted Publishing 发不了首版 —— npmjs.com 要到包已经存在之后才能配 Trusted Publisher(上游 [npm/cli#8544](https://github.com/npm/cli/issues/8544) 仍 open)。首发之后再去包设置页配 Trusted Publisher(Provider: GitHub Actions;填 owner / repo / `release.yml`);⚠️ **新建配置必须在 2 天内完成一次成功发布**,否则作废且无法编辑 —— 所以配的时机是"接下来两天真的会发一版",给一个已经在 npm 上的版本建 Release **不算**验证。
389
+ 4. **第一个版本**:OIDC **直接** `npm publish` 发不了首版(npmjs.com 要到包已经存在之后才能配 Trusted Publisher,上游 [npm/cli#8544](https://github.com/npm/cli/issues/8544) 仍 open)。0.4.0 实测走的是 **staged publishing**:`npm stage publish`(registry 见到包还不存在,先建一个占位版本 `0.0.0-stage` 把包名占住)→ 维护者在 npmjs.com 的 **Staged Packages** 页点 Approve,或 `npm stage approve <stage-id>`,用 **2FA approve** 之后才公开。
390
+ ⚠️ **打包/发布用的不是 homebrew 那套 npm**:本机 `/opt/homebrew/bin/npm` 是 11.12.1,**没有 `stage` 子命令**;发布用的是 `/Users/y/tools/node-v24.19.0-darwin-arm64` 里的 npm **11.17.0 / Node 24.19.0**。只有它的 `lib/commands/publish.js` 有 `stage: true` 那条路,`npm publish` 仍然走 `otplease`(当场要 OTP)。同一份内容被两套 npm 打出来的 tarball **字节不同、内容相同** —— 别拿 shasum 不等当"发错东西"的证据,要比逐文件 sha256。
391
+ 首发之后再去包设置页配 Trusted Publisher(Provider: GitHub Actions;填 owner / repo / `release.yml`):⚠️ **Allowed actions** 决定以后怎么走 —— `npm stage publish` 永远允许,而"直接 `npm publish`"要**显式勾选**;只勾 staged 的话 `release.yml` 得改成 `npm stage publish`,而且每版都要人工 approve。⚠️ 新建配置**必须在 2 天内完成一次成功发布**,否则作废且无法编辑(给一个已经在 npm 上的版本建 Release **不算**验证)。
334
392
  5. 发布后核对产物:`npm pack --dry-run` 里必须带上 `index.js` / `client.js` / `cordis.patch.yml` / `icon.svg` / `locale/en.json` / `LICENSE`,以及 README 与 AGENTS 链接到的全部文档(`README_EN` / `CONTRIBUTING` / `REVIEW` / `CHANGELOG` / `AGENTS`)与 `tools/`(事故包渲染器 + 三个 CDP 验证器,README 让用户直接跑它们,所以必须进包)—— 漏一份,npm 页面上就是一个死链。`release.yml` 的 Pack and inspect 步骤逐份 grep,漏了就失败。
335
393
 
336
394
  ---
337
395
 
338
396
  ## 11. 当前状态
339
397
 
340
- **v0.4.0 已发布到 npm**(2026-10-11 手工首发):`npm test` **458** 条全绿(attribution 11 / logic 408 / host 39)、
341
- `npm pack --dry-run` **18 个文件**、`tag v0.4.0` 与 `version` 一致且已推 origin。
342
- **首发只能手工** —— OIDC / Trusted Publishing 发不了第一个版本(npmjs.com 要到包存在之后才能配 Trusted Publisher,上游 [npm/cli#8544](https://github.com/npm/cli/issues/8544) 仍 open);下一个版本起走 Release 触发 `release.yml`。
398
+ **v0.4.0 已发布到 npm**(2026-10-11,`latest → 0.4.0`):`npm test` **458** 条全绿(attribution 11 / logic 408 / host 39)、
399
+ `npm pack --dry-run` **18 个文件**、`tag v0.4.0` == registry 的 `gitHead` == `dbcd8f3`(线上 tarball 与本机 `npm pack` **逐文件 sha256 全等**)。
400
+ 首发走的是 **staged publishing**,不是直接 `npm publish`:`npm stage publish`(npm **11.17.0** / Node 24.19.0;registry 先建占位版本 `0.0.0-stage` 占住包名)→ 维护者 2FA **approve**。OIDC **直接** publish 发不了首版(npmjs.com 要到包已存在才能配 Trusted Publisher,上游 [npm/cli#8544](https://github.com/npm/cli/issues/8544) 仍 open);下一个版本起走 Release 触发 `release.yml`,要不要每版人工 approve 取决于 Trusted Publisher 的 **Allowed actions**(见 §10 第 4 条)。
343
401
 
344
402
  **0.4.0 收进的改动**:「帧里花在哪」从三段饼改成四段饼
345
403
  (脚本 / 任务里没归到脚本的 / 渲染 / 样式+布局+绘制),脚本侧再拆三层并分成**本帧与窗口两个分母**;
346
404
  随后一轮 UI 改版:折叠组变五组(新增「包 × 来源 · 窗口」`cross`,默认收起;`group()` 的 children 改成 thunk,
347
- 收起时不算)、60 秒帧条铺满一行并挪到「取证明细」最前、可作帧导航、事故与帧改成按 id / 就近定位共享一个选中上下文、
405
+ 收起时不算)、60 秒帧条铺满一行并挪到「取证明细」最前、可作帧导航、事故与帧改成按 id / 就近定位共享一个选中上下文
406
+ (其中「就近定位」那一半 **2026-10-11 已被替换**:历史帧直接来自事故包自己的 `incident.frames`,`frameNearestIncident` 删除 —— 见 §7 的「诊断上下文 / 历史帧的逐帧回看」两条)、
348
407
  DOM 写入挪进「DOM 与堆」、`回调归因` 未插桩时降级成一行 `要深录`;
349
408
  再一轮「证据 → 代码」完善:新增「可疑脚本」块(函数名 / 包 / 文件 / 行号 + 直接测量 vs 归因推断 vs 无法确认三档口径 + 一键复制),
350
409
  `summary.topScripts[]` 多带 `invoker`/`file`/`line`/`lineVia`;顺手修掉组合 URL 的 `pathname` 截断(文件路径显示成 `/plugins/`)与
@@ -353,13 +412,18 @@ DOM 写入挪进「DOM 与堆」、`回调归因` 未插桩时降级成一行 `
353
412
  又一轮诊断链完善:**历史事故改为用捕获时存下来的定位**(`incidentScriptEvidence` / `incidentEvidenceAt`,面板与复制摘要共用一条路 —— 以前现算,刷新后缓存空了就把存好的 pkg/file/line 丢掉);
354
413
  合并脚本的「文件」与「行号」拆开显示(`lineScope`,`合并脚本行 N`,不再拼成看着精确的 `file:line`);新增**事故回放**(只用事故包里的 frames/loaf,刷新生效、零新增采集,且不排帧内执行顺序);
355
414
  删掉 REVIEW §3.4 记的不可达分支 `pkgZeroText`(连同它的函数),断言改成实际渲染的数据。
356
- `npm test` 现在 **458** 条(attribution 11 / logic 408 / host 39),CI 门禁同步成 `-eq 458`;真页面 `check-panel` 验过(见 REVIEW §2)。
415
+ **2026-10-11(第二轮):历史事故逐帧诊断 + 双上下文隔离(未发布)** —— 取证明细只服务一个上下文(`live` / `incident`),
416
+ 分流点只有 `detailContext()` 一处;历史帧条是**导航器**(240 列,每列取该列最差的一帧,点击映射回 `incident.frames` 里的真实记录,
417
+ rAF 挂起的区间留白),四个方向翻帧(没有下一条就禁用),选中帧的分解 / 脚本 / long task / 证据全部只吃那一帧(缺就是缺,不回退);
418
+ 归因状态收敛成**已确认 / 可疑 / 未归因**三档并去掉"更像 rAF 被节流"这类把可能写成结论的说法;诊断上下文落盘 `LS_WATCHUI`,
419
+ 刷新后仍停在同一条事故的同一帧。`npm test` 现在 **606** 条(attribution 11 / logic 554 / host 41),CI 门禁与文档同步成 `-eq 606`;
420
+ 真页面验收:`check-panel` / `check-storage` / `check-interaction` / **新增 `tools/check-incident.mjs`** 四个验证器 **ALL PASS**(见 REVIEW §2)。
357
421
 
358
422
  **仍然存在的债(套件是绿的 ≠ 它们没问题)**:
359
423
 
360
424
  - ~~`pkgZeroText` 的「0 条定到名(带分母)」分支实际不可达~~ → **已还**:死分支与函数都删了,断言改成实际渲染的数据(见 §7)。
361
425
  - `tallyScripts` 算出的 `unresolvedMs` **全仓没有读者**(只有定义处与测试断言引用它)。
362
- - `stressText` / `pkgWhoText` / `invokerWhoText` 三个函数**零调用点**,却被 `tests/logic.mjs` 覆盖着 —— 这正是"套件绿"与"代码没死"之间的差距:断言只证明函数还算得对,不证明面板还会调它。
426
+ - ~~`stressText` / `pkgWhoText` / `invokerWhoText` / `replaySplitText` 等零调用点函数~~ → **已还(2026-10-13)**:`experimentText` / `beyondNoiseText` / `metricList` / `stressText` / `eventTitleText` / `tallyText` / `pkgWhoText` / `invokerWhoText` / `replaySplitText` / `levelDown` 十个函数连同它们在 `tests/logic.mjs` 里的 `extract` / `pieces` / 断言、以及只被它们引用的 13 个词典键一起删了(函数删了断言必须一起删,否则 `extract` 直接抛)。这正是"套件绿"与"代码没死"之间的差距:断言只证明函数还算得对,不证明面板还会调它。死代码不许回来 —— `node tools/dead-code.mjs` 现在是 0 个候选。
363
427
  - 悬停联动**停用且辅助代码已删干净**(`hot`/`setHot`/`hotProps`/`hotStyle` 都没了):2026-10-08 会让外壳停用整个条目,2026-10-11 在当前代码上**复现不出来**(163 个元素真实鼠标 + 153 次 JS 派发 `mouseover` 全程存活),既不能证明根因也不能证明已修好 —— 恢复前必须先复现出来(配方在 §7),不要把它当可用特性写进任何文档。
364
428
  - **原始源码行号 / 调用栈 / 函数级耗时仍然拿不到**:要解 section 内层 `mappings`(VLQ)或录 Chrome trace。「可疑脚本」只能缩小到"哪个包的哪个文件哪一段"。
365
429
  - **函数名经常缺失**:LoAF 的 `sourceFunctionName` 在合并/压缩脚本里常常是空 —— 面板照实写「函数名未记录」,这不是 bug。
@@ -369,7 +433,7 @@ DOM 写入挪进「DOM 与堆」、`回调归因` 未插桩时降级成一行 `
369
433
  —— ⚠️ 新建配置**必须在 2 天内完成一次成功发布**,否则作废且无法编辑,所以**别提前配**;
370
434
  ② 收录 PR 还没提。
371
435
  `release.yml` 对"该版本已在 npm 上"幂等(发布前查 registry,查到就跳过;查询失败照实报错),所以给手工首发那一版建 Release 只会走"跳过"分支,不会再发一次。
372
- 三条新发现的尾巴(A5 还没接进客户端/导出件、D4 的 `stressText()` 死代码、`locale` 的打包边界)也记在 REVIEW §3.2。已完成的验收证据在 REVIEW §2。
436
+ 三条新发现的尾巴(A5 还没接进客户端/导出件、~~D4 的 `stressText()` 死代码~~(2026-10-13 已随死代码层删掉;`stress()` 夹具保留,仍然没有可读结果)、`locale` 的打包边界)也记在 REVIEW §3.2。已完成的验收证据在 REVIEW §2。
373
437
 
374
438
  ---
375
439
 
package/CHANGELOG.md CHANGED
@@ -2,6 +2,117 @@
2
2
 
3
3
  本项目遵循 [Conventional Commits](https://www.conventionalcommits.org/),版本号遵循 SemVer。
4
4
 
5
+ ## [0.5.0] - 2026-10-18
6
+
7
+ ### 面板(2026-10-17,标题栏:一行永不换行)
8
+
9
+ - **标题栏改成不可换行的一行**:`S.head` 去掉 `flexWrap`,显式分成 `headLeft`(可压缩:`minWidth: 0` + `overflow: hidden`)与 `headRight`(控件,`flex: 0 0 auto`);标题与场景标签可让位(ellipsis),**控件不让位**。以前那一行里每个元素都是 `flex: 0 0 auto`(§5「标签不收缩」本来是给**数据行**写的),总宽一超,浏览器就按 DOM 顺序把**尾部三个控件整组**丢到第二行、还左对齐 —— 主界面一行放得下、卡顿详情放不下,于是"两个头部的按钮位置关系不一样",而那三个其实是全局控件。分组也不再靠 fps 上的 `margin-left: auto`(它只在所在那一行生效)。
10
+ - **量出来才敢改的宽度账**(真页面 `getBoundingClientRect`,396 面板 = 372 内容宽):zh 详情页原来差约 14px、开发者详情差约 80px。收紧头部间距与按钮内边距(8→6、`padding: 2px 6px`,只作用于头部 `S.headBtn`)后,**zh 详情页仍差 0.17px** —— 亚像素级的 flex 收缩就足以触发省略号(`scrollWidth` 取整看不出来,得看小数)。所以又两处:① **「深录」从标题栏移到开发者详情的标签页行**(它是唯一"只在某一屏出现"的头部控件,却占着最挤那一行 —— 实测把该屏标题挤到 21px;那一行本来就换行);② 返回键文案 **「← 返回」→「←」**(悬停给"回到主界面")。现在四个视图的头部都是**一行、零溢出、零裁剪**:标题 70 / 56px 完整、场景完整、控件在右端。
11
+ - **子页标题不再拼品牌名**(「性能 HUD · 卡顿详情」→「卡顿详情」,品牌名进悬停):它与下面第一行的上下文标题(`历史事故 i35 · 历史帧 23/56`)在回答同一个问题("我在哪")。
12
+ - **回归测试与验收底座**:新增四条静态断言(头部不换行且显式两组 / 没有 `margin-left: auto` / 只有标题与场景可让位 / 头部按钮 ≤4 个 / 深录不在标题栏而在标签页行)+ 一条词典长度预算(视图名 zh ≤5 字、en ≤17 字符);`tools/cdp.mjs` 的 `waitForHud` 就绪判据从"`innerText` 里含『性能 HUD』"改成"**根节点存在且文本非空**" —— 子页标题不再含品牌名,而 profile 里落盘的视图是滚动的,旧判据会把"上次停在开发者详情"报成"boot 失败,或槽位条目被外壳停用"(实测踩过);`check-incident` / `check-counts` 按新文案点返回键,真页面四件套全绿,九张 README 截图重出。
13
+
14
+ ### 面板(2026-10-17,卡顿详情分层与界面去重)
15
+
16
+ - **卡顿详情那一屏分两层**:结论层常显(上下文标题 / 复制摘要 / 导出事故包 / 回放范围 / 归因状态 / 历史帧时间轴),技术读数收进**默认折叠**的「技术细节」(`state.groups.tech`)—— 与开发者详情**共用同一份渲染**(`techGroup`)。以前同一份 `incidentDetail` 在开发者详情里是可折叠的、在普通用户点「查看这次卡顿」落到的那一屏却是**全平铺**的(实测一屏 12 个按钮、约 10 块,含三行"这一帧没有记录"),方向正好反了。
17
+ - **同屏重复删掉**:视图外那一行动作里的第二个「← 返回」删除(返回只属于标题栏);事故 id 与"这一帧多少 ms"只在上下文标题里印一次(时间轴卡里不再重复);这一帧没有 LoAF 时,「帧里花在哪」「重叠的 long task」「可疑脚本」那三行"没有记录"合并成一行 `techFrameNone`,收起时那一组摘要给 `techFrameNoneShort`。
18
+ - **翻帧从四个按钮收敛成两个箭头 + 一个过滤器**:`incidentFrameNav(row, frame, onlyBad)` 与 `incidentFrameStep(±1)` **共用 `incidentFrameHot` 一个判据**,所以"按钮亮着"与"按下去会到哪一条"永远是同一件事(以前是两对箭头做同一件事、只是步长不同);`onlyBad` 与 `mode` / `selId` / `frameT` / `scope` 一起落在 `LS_WATCHUI`,`layoutDirty()` / `resetLayout()` 也认它。真页面验收器同步改成:打开「只看异常」→「下一帧」必须落在达线帧上 → 再点一次取消。
19
+ - **开关与按钮各归其位**:「深录」只在**开发者详情标题栏**与设置页(以前每个视图的标题栏都挂着一份,普通用户在卡顿详情里看到它只能猜);主界面的「设置」入口删掉(标题栏常驻,同屏不必两份);「最差事件」块只留上下文切换按钮(复制摘要 / 导出事故包属于卡顿详情自己的动作条);「工具箱」只留守夜状态 + 事故清单 + 标记刚才,导出与清空只在「原始数据」。
20
+ - **测试与文档**:断言 604 → **606**(attribution 11 / logic **554** / host 41 —— 补「只看异常」的可用性判据与步长落点两条,加「技术细节」摘要的长度预算);词典各 402 → **405**(删 `frameNavPrevBad` / `frameNavNextBad` / `frameNoLoaf`,增 `groupTech` / `techFrameNone` / `techFrameNoneShort` / `techNoneHint` / `onlyBad` / `onlyBadHint`);`tools/check-incident.mjs` 起手连折叠状态一起清掉,验收从**默认布局**的第一屏开始;`tools/shots.mjs` 起手强制写 `dsh-perf-hud:on=1` 再刷新 —— 那个 Chrome profile 里落盘的 `'0'` 会让造样本那一步只报"没抓到事故",真因是 `capture()` 回 `not-recording(HUD 关闭时不记录)`;九张 README 截图重出。
21
+
22
+ ### 可靠性(2026-10-17,参考 DSH 自带开发者工具)
23
+
24
+ - **异步读取全部有 deadline 与代际**:`hostOp` / `probeLink` / sourcemap / 脚本文本读取原来都是裸 `fetch`,宿主挂住时面板会一直显示旧数、`text-pending` 会永远停着,`stop()` 也不 abort 在途请求。现在统一走 `fetchTextWithin` / `fetchJsonWithin`(宿主 5s / map 与脚本文本 20s / probe 30s),失败只写 `timeout` / `cancelled`;`start()` 递增 `state.epoch` 并建 `AbortController`,`stop()` abort 当前代,旧代结果一律丢弃。补静态断言:源码里的 `fetch(` 必须带 `signal:`。
25
+ - **宿主快照从"两态"改成四态**:`live / warming / stale / unsupported`(外加路由不可达)。以前 `eventLoop` 对象存在但 ring 还空时,面板会把 `p50/p99` 渲染成 `nullms`,更糟的是 `infraVerdict()` 会在**零样本**时返回「链路已排除」。现在零样本给中性「宿主数据不足」,超过 2.5 个轮询周期标「已过期 Ns」并降级判定;`eventLoopLive=false` 写「宿主未采到」;宿主从 404 恢复后重新请求 `session/event` 监听(不再因为第一次失败就永久 `hostWatchAsked`)。
26
+ - **事故窗口完整性**:LoAF / longtask 条目带 `seq`,落定时算 `expected − kept`,写进 `incident.integrity = { captureComplete, loafDropped, longTaskDropped, loafKept, longTaskKept, reasons }`。前摇已经滚出环(环满、确实丢过记录,且最老一条晚于窗口起点)单列 `*-pre-window-overflow`,不编缺失条数。`captureComplete=false` 时:面板写「现场不完整」、`missing` 加 `window-integrity:` 行、`manifest.complete=false`;`hiddenGaps`(rAF 挂起)仍是另一个维度。诊断包 `PACK_SCHEMA_VERSION` 1→2,事故包 `schema` 2→3。
27
+ - **map 缓存有上限与淘汰**:`MAP_CACHE_MAX=8` + LRU 索引 `dsh-perf-hud:map:index`;写配额失败先淘汰最旧 rev 再重试一次,仍失败只留内存并记 `mapCache.failed`。内存 `bundleMaps` 同样 LRU。整机导出件的 `context.mapCache` 给出 `max / evicted / failed`。
28
+ - **快捷键**:`ctx.shortcuts` 可选注入,macOS / Windows `Cmd/Ctrl+Shift+U` 展开/收起;Linux Web 平台白名单不允许这个组合,不硬塞默认绑定。收起/展开**不抢焦点**(实测外壳会把焦点还给输入框,硬抢会让用户丢焦点;因此没有照抄 Inspector 的 focus restore)。
29
+ - **真事故 fixture**:`tests/fixtures/incident-real.json` 是真页面 `capture` → `report` → 脱敏后的切片(74 帧 / 21 条 LoAF / 2 条 longtask / `integrity.captureComplete=true`)。离线套件断言形状、完整性字段,以及"不完整现场"时 summary/manifest 的措辞。
30
+ - **深录 sourcemap 解析先测再决定**:当前构建 17 个 section,清 map 缓存后 `setPrecise(true)` 整段墙钟 514ms(含网络),**没有 >50ms 的 longtask**,所以本轮不搬 Worker;后续若出现 >50ms 的自有长任务再重测。
31
+ - **文档**:AGENTS 新增 §0.2「与 DSH 自带开发者工具的分工」与 §7.1「记录器可靠性三条」;README 中英新增分工表、快捷键、宿主过期 / 现场不完整说明;词典各 396 → **402**(新增完整性 / 宿主状态 / 快捷键文案);断言总数 586 → **604**(attribution 11 / logic 552 / host 41),CI 与 README/REVIEW 同步。
32
+
33
+ ### 重构
34
+ - **死代码层清掉:`4393ea8` 撤面板入口时没收回来的函数、状态位与词典键**(2026-10-13,`review/2026-10-13-audit` 的实施)。`experimentText` / `beyondNoiseText` / `metricList` / `stressText` / `eventTitleText` / `tallyText` / `pkgWhoText` / `invokerWhoText` / `replaySplitText` / `levelDown` 十个函数在 `client.js` 里**零调用点**,却被 `tests/logic.mjs` 抽出来断言着 —— **改坏了没有任何界面会变,CI 却全绿**(与已还的 `pkgZeroText` 同一形态)。现在函数、`extract` / `pieces` 条目与对应断言一起删(`metricList` 是删 `beyondNoiseText` 之后才暴露出来的级联),连带只被它们引用的 **13 个词典键**(中英各 409 → **396**)、`state.markDraft` 与 `PerfHud()` 里三个未用局部(`v` / `tally` / `unexplained`)。重新扫:**0 个候选**(`node tools/dead-code.mjs`,判据保守,不是门禁)。断言 603 → **586**(attribution 11 / logic 534 / host 41);随后「可靠性」一轮把 logic 加到 **552**,总数回到 **604**。
35
+ - **`snapshot()` 不再是"读 + 写"**:`sessionBaselines()` 以前直通 `saveSessionBaseline()`,于是**文档承诺的只读 API 每被调一次就重写一遍 `localStorage` 的跨会话基线**(五个真页面验收器每轮会调它若干次,多数只为读 `errors`)。现在拆成 `readSessionBaselines()`(纯读)+ `mergeSessionBaseline()`(合并本次会话但**不写盘**)+ `saveSessionBaseline()`(只由 `pagehide` 调),导出件的内容**逐字不变**(仍含当前会话,仍最多 20 条),只是不再落盘;新增断言钉住"调 `sessionBaselines()` 前后 `localStorage` 一个字节都没变"。
36
+ - **帧条不再白扫整个环**:`frameRing.slice()` 从环头正扫改成**从最新一帧倒着走、遇到 `at < from` 就停**。环容量 12000(60fps ≈ 3.3 分钟),而调用方只要最近 60 秒(≈3600 条)—— 展开「取证明细」时每个渲染 tick 白扫约 8400 条,正是"测量工具自己变成被测开销"那条边界要挡的事。倒扫依赖"环按时间升序"这条不变式(生产里每帧只经 rAF 循环 push 一次);一条构造出乱序环的旧用例因此改用 `triggerCapture` 起捕获,并把这个不变式写进注释。
37
+ - **导出件的 `timeline.last60s` 删掉**:它是 `timeline.samples` 的子集(500 条环约 106KB,它重复约 13KB),而全仓唯一引用是一句 `Array.isArray`。
38
+
39
+ ### 修复
40
+ - **诊断包 `manifest.files` 的顺序说错了**:manifest 在描述"这份包里有什么"时把 `summary.md` 排在第一,而 `buildPack()` 与 UI 词典 `packFilesValue` 都是 `manifest.json` 在最前(它是入口,注释也这么写)。现在三处一致,并补一条断言(manifest 描述的顺序 == 包里真实的顺序)。
41
+ - **折叠态与展开态对同一个数用了不同粒度**:宿主摘要写 `宿主 24.04ms`(实际是 p99),展开那一行写 `p50 X · p99 Y · 最大 Z` —— 读者会把折叠态的数当成均值。现在摘要也写 `p99 Xms`(宿主与 RTT 两处)。⚠️ 按 review 的原话"两处都加 p99"会把这一行从 33 字符顶到 41,而摘要预算是 **34**(真机截图里折成了两行)—— 所以同时把**判定那一句从摘要里拿掉**:它已经是展开后的**第一行**(标签 `宿主 event-loop · 判定`),摘要再说一遍就是同一句话说两遍;而且它最长那句(`链路已排除(宿主与隧道均正常)`,16 字)本来就把摘要顶到 43 字符,那个折行**改动之前就有**(桩喂 `未采集` 的那条断言没测到)。现在真实量级(四位数 + 小数)下是 `宿主 p99 1234.5ms · RTT p99 1234.5ms` = 34,一行放得下;并补了一条**喂真实量级**的预算断言(桩里的小数字挡不住这类回归)。
42
+ - **工具箱折叠摘要与展开后第一行说同一句话**(`守夜 5 次 · 最差 282ms` vs `抓到 5 次 · 最差 282ms`):摘要改成折叠态独有的信息 `守夜 N 次 · 24h 检测 M 次`(跨刷新、24 小时口径),`抓到 / 最差 / 读回 / 裁掉` 仍只由展开后那一行给。
43
+ - **`localStorage` 键位清单两处都不全**:面板那行只列 7 个、README 列 11 个,而代码里有 **13 个**;两处都漏了 **`:deep`**(它决定下次打开是守夜档还是深录档)。现在面板与 README 对齐成同一份 13 项清单,并把"会改变采集档位"的三个(`:on` / `:deep` / `:precise`)点出来。
44
+ - **`__perfHud.marks()` 在 README 里承诺过却没实现**:`4393ea8` 撤「场景标记」面板入口时给的出口一直是空的。现在补上只读 getter(`marks: () => state.marks`),并加静态断言防止它再消失。
45
+ - **`crossSummary`(第 5 个折叠组摘要)没有长度预算**:真实量级的名字 `dsh-client-ui-renderer · DOMWebSocket.onmessage 1234ms` = **53** 字符,在 396px 里会把摘要折成参差两行。现在 `clipName()` 把两个名字各截到 14 / 15 字符(完整名字在展开后的交叉表里),并补上**拿真实量级名字喂**的预算断言(≤ 40 字符 + 必须出现省略号)。
46
+ - **`check-counts.mjs` 的"有时绿"**:它等 `pending === null` 就立刻 `capture()`,但等待期间守夜可能又自己触发一次,手动标记被并进 pending、样本不带备注 —— 第一次跑 3 条 FAIL、重跑三次全过。现在看 `capture()` 的返回值(`{ok, reason}`)**重试**,并按 `capture().incident.id` 取回刚标记那条,不再用"谁有备注就取谁"的启发式。⚠️ 实施时又发现**第二层**:手动标记的 severity 是 **0**,留存策略是"最差 10 + 每天最差 1" —— 页面里已有 8~10 条真实卡顿时这条标记会被**挤掉**,样本根本不在清单里;不清残留的话,旧写法会取到**上一轮留下的那条**,三条 ZIP 断言验的就不是这次的标记(实测:接在 `shots.mjs` 之后跑必翻车)。所以 `check-counts.mjs` 与 `shots.mjs` 都先 `clearIncidents()`(不动计数桶)再取样 —— 验收器不许依赖上一轮的残留。这种抖动比红更糟:它会训练人忽略失败。**顺带**:`shots.mjs` 的样本竞态与残留事故同样修掉(README 的图里也不该出现使用者真实会话的证据)。
47
+ - **宿主半的 confirm 安全线只钉住了一半**:`setBundle` 的 deny 名单有两项(自己 + `session-persistence-jsonl`),而 `tests/host.mjs` 只测了"拒绝动自己"。补上第二条,并断言两条都拒绝时**一次都没写**。
48
+ - **README 的两处漂移**:`exportPack` 那行还写着"逐个文件下载(P1 换成一个 zip)"(zip 已在 `f92399e` 落地);`NEXT-PHASE-PLAN.md` 的"下一轮"清单里 zip 也还在待办。
49
+
50
+ ### 新增
51
+ - **诊断包改成一个 ZIP 一次下载**(`zipStore()` / `crc32()`,**零依赖、store 模式不压缩**)。P1 之前是逐个下载 4 个文件 —— 部分浏览器会把后续下载拦掉,用户文件夹里也会散落四个文件。现在 `exportPack(id, options)` 打一个 zip,名字带 `-ai` / `-full`,两条路径落到磁盘上也分得清。不压缩的理由:包里全是 JSON / Markdown,deflate 能省一半体积,但要么引依赖、要么依赖异步的 `CompressionStream`,为几十 KB 不值得;CRC 用逐位算法(一次打包只调 4 次,省掉一份模块级查表状态)。预览卡从"每份大小"改成**压缩包总大小 + 逐份清单**,并删掉那句"一次打包成一个 zip 是下一步"的过渡文案。
52
+ - **主界面的「最近一次卡顿」用时间语义,与开发者详情的「最差事件」分开**(`latestIncident()`)。原来主界面吃的是 `selectedIncident()`,而它默认跟**全局最差**那条 —— 于是一小时前那次最严重的会被显示成"最近一次"。现在两个语义各有各的函数、各有各的视图:主界面 = 时间最新,开发者详情 = 默认最差 / 用户手选;结构上有断言钉住"主界面不碰 selectedIncident"。
53
+ - **候选原因给普通用户说人话**(`causePlainLead()`):主界面只给"某个脚本模块的耗时偏高 / 这一段时间里有较重的渲染任务 / 宿主进程那侧偏忙 / 缺少能解释这次停顿的 JS 证据" + 线索强度,**不带包名**;原始包名 / 毫秒 / 支持证据 / 出处收进「为什么这样判断?」展开区(默认收起),开发者详情里照旧全给。趋势那一行的「重复线索」同样只说"同一个脚本模块",包名进展开区。不编模块功能,也不把相关写成根因。
54
+ - **两条导出路径在文案与 UI 上明确区分,并加分享前预览**:给 AI 的那条叫「导出诊断包(给 AI:脱敏、不含备注)」,带 `dropFreeText: true`;本机调试件叫「完整技术导出(保留备注)」。主界面点「导出诊断报告」**先出预览**(文件清单 + 每份大小 + 备注已置空 + 本包不含多少项 + "一次打包成 zip 是下一步"),确认后才生成;`packPreview()` 新增 `notes: 'dropped' | 'kept'`,两条路的差别因此可断言。现有 JSON 接口(`snapshot` / `report` / `exportJson` / `exportIncident` / `pack` / `preview` / `exportPack`)一个都没改签名。
55
+ - **界面改成四个视图(方案 1:整屏切换,不再把摘要贴在旧面板顶上)**。默认是**主界面**,只回答五个问题:当前状态(`healthVerdict()`:运行流畅 / 刚才有卡顿 / **监测受限** —— HUD 关着或 LoAF 没装上时给的是后者 + 原因,绝不伪装成正常)、最近一次卡顿(秒数 + 时刻 + 现场有没有留住)、可能原因(`causeCandidates()`,每条都带**支持与限制**)、最近趋势(1 小时 / 24 小时可切:检测到几次 / 最长一次 / 重复线索)、两个主操作(查看这次卡顿 / 导出诊断报告)。**卡顿详情**是独立一屏(「← 返回」);**开发者详情**是 5 个标签页(**帧与归因 / DOM 与堆 / 宿主与链路 / 工具箱 / 原始数据**,照实映射现有分组,现有能力一个都没删);**设置**管记录模式(守夜默认常开、深录必须显式开)、数据保留与隐私脱敏。落盘只存"在看哪个视图 / 哪个标签页 / 哪个趋势窗口"(`LS_VIEW`),**不存任何读数**;折叠药丸展开时回到主界面。主界面**不出现** LoAF / p99 / RTT / 包名排行 / 帧编号。
56
+ - **能力矩阵 + 诊断包:把"哪一层采到了、可能是什么原因、缺什么"固化成可断言的数据**(`capabilities()` / `capabilityMissing()` / `causeCandidates()` / `buildPack()`)。`capabilities()` 是能力状态的唯一事实来源(renderer / host / link 三组叶子 + `unobserved` 一层:GPU 合成、Electron 主进程、原始源码行号、帧内顺序、DevTools trace),每片叶子给 `{ state, provenance, reason }`;事故包那串 `missing:` / `howToAdd` 改由 `capabilityMissing(row)` 从它推出来(前缀形状不变,线上断言照旧认)。`causeCandidates(row)` 给**最多 3 条候选原因**,每条带**支持证据 / 限制 / 出处三档**(`measured` / `inferred` / `unknown`),而且限制里一定有"不等于已定位根因";`worstFrameJs === 'none'` 时不会顺手写节流结论(只有 `suspectedThrottle` 成立才写"更像 rAF 挂起或浏览器节流")。`__perfHud.pack(id)` 打出**四个文件**:`manifest.json`(入口:schemaVersion、平台、采集档位、能力矩阵、脱敏、不含什么)/ `summary.md`(固定五段:摘要 → 最可能的假设 → 缺失的证据 → 下一步验证 → 元信息)/ `incident.json`(与面板「导出事故包」同源)/ `README.md`(怎么读 + 隐私口径);`preview(id)` 只给清单,`exportPack(id)` 才下载(P0 逐个文件,P1 换成一个 zip)。构建全是**纯函数**,不碰 DOM —— 所以离线套件直接断言格式,以后换界面 / 加 MCP 不用动这份格式。
57
+ - **捕获频次:跨刷新的时间桶,与"留存了几份现场"彻底分开**(`state.tally` / `LS_TALLY` = `dsh-perf-hud:tally:v1`)。留存清单受容量限制(最差 10 + 每天 1),**它的条数不能回答"最近卡了几次"** —— 被挤掉的卡顿不是没发生(实测一次会话里 `captured 40 · dropped 38`)。现在每分钟一个 7 元桶、只留最近 24 小时,再按天汇总 30 天;落盘走 2 秒合并定时器(`markTallyDirty`:后摇窗口里每帧都会触发,不能每帧写盘),读回时形状不对的桶**整桶丢掉**并把原因记进 `tally.loadError`。四个数各有各的名字,面板悬停与导出件(`context.tally`)都写全:**检测到 = `triggered`**(起过几次捕获)**/ 同串触发 = `coalesced`**(同一串里被并掉的触发数,是密度不是次数)**/ 已保存现场 = `incidents.length` / 丢失 = (triggered − captured) + dropped + trimmed + failed**。折叠药丸那一格从"留存份数"改成"最近 24 小时检测到几次"(它原来把 `incidents.length` 显示成"抓到 N 次",正是产品计划 §3.A 要拆开的那个混淆)。`clearIncidents()` **不清**计数桶(清掉的是证据,不是事实),要清频次得显式 `clearTally()`。
58
+
59
+ - **导出脱敏:任何离开本机的导出件先做一遍减法,并把它减了什么写进文件**(`redactExport()` / `redactionReport()`,**导出件只有这一个出口**,以后加字段不会漏)。四条规则:① `page` / `href` 只保留 `origin + path`、**查询串整段丢弃**;② `url` / `urls` / `sourceURL` 去掉协议与主机、保留路径与 `rev`,而 `token` / `accessToken` / `api_key` / `secret` / `password` / `signature` / `code` 等键的**值**换成 `<redacted>`(键名保留 —— 读的人要知道这里本来有什么);③ 其余字符串里的 `/Users/<用户名>`、`/home/<用户名>`、`C:\Users\<用户名>` 统一换成 `~`(所以错误原文、组件栈、宿主 profile 路径这些"顺手带出来的东西"也被一并覆盖);④ `userAgent` 只留浏览器主版本 + 系统。"其余字符串一律过一遍"这一条是刻意的:按字段名逐个加规则一定会漏,而漏一个就是一次泄漏。文件里 `context.redaction` 给三问三答 —— `applied`(做了什么)/ `kept`(留了什么)/ `neverCollected`(本来就没采:聊天正文、提示词、Cookie、Token、请求头),口径写明**这是减法不是匿名化**。`incidentReport(id, { dropFreeText: true })` 把用户手敲的自由文本(事故 `note`、场景标记)置空并标 `noteDropped: true`,默认(本机调试件)原样保留。
60
+
61
+ - **"检测到"一开始被写成 `captured + coalesced`,真页面上把一次停顿记成 18 次**:同一串卡顿在 3 秒的后摇窗口里**每帧都会触发一次**,于是 `coalesced` 能到两位数 —— 两者相加得到的数"看着挺忙",却答不了"卡了几次"。现在 `detected = triggered`(`triggerCapture` 起一次新 pending 才 +1),`coalesced` 单独作为"这一串有多密"给出。这条是**先在真页面上量出来**才发现的:离线盒子里两种写法都能算出一个数,只有真页面的触发密度能暴露它。
62
+ ### 修复
63
+ - **AI 诊断包的 `summary.md` 会把用户备注原样带出去**:`incident.json` 那边按 `dropFreeText` 置空并标了 `noteDropped`,而 `diagnosticSummary()` 直接读了原始 `row.note` —— **同一份包里两份文件各说各话**,而这份包正是要交给外部 AI 的那份。现在摘要走同一个 `dropFreeText`(置空并写一句"备注:已置空"),保留那一支也会过 `scrubParams`。**这条是被新加的断言抓出来的**(断言拿一份 `note` 里带 `token=SECRET` 的事故跑两条路径)。
64
+ - **自由文本里"裸的"凭据没被脱敏**:`scrubParams` 原来只认 URL 形态(`?token=…` / `&api_key=…`),而用户粘贴的备注常常是 `token=abc` 这种没有前缀的写法。现在按**词边界**再扫一遍(代价是 `code=200` 这类无关键也会被换成占位符 —— 宁可多脱一个,不要漏一个)。
65
+ - **8 个文案只有引用、没有词典条目,面板上直接印出了 key 名**(`viewIncident` / `backHint` / `exportPreviewCopy` / `packFilesLabel|Hint|Value` / `storageKeys|Hint`)。三套件全绿、死键断言也不响(它只管"词典里没人用"),只有真页面才看得出来。补齐词条,并**补上反向断言**:凡是被 `t('…')` / `translate('…')` 字面量引用的键,必须真的在 `DICT.zh` 与 `DICT.en` 里。
66
+
67
+ - **`snapshot().page` 与 `incidentReport().context.page` 原来直接写 `location.href`**:DSH 首屏是 `http://127.0.0.1:3080/?token=…` 进来的,于是**一次导出就可能把会话 token 一起带出去**,而这份文件正是准备交给外部 AI 的那份。现在按上面那条脱敏规则处理,并有端到端断言:往真实状态里塞 `?token=SECRET_TOKEN_VALUE`、`/Users/tester/…`、`api_key=SECRET_KEY_VALUE`,断言序列化后的导出件里这三个字符串一个都不剩。
68
+
69
+ - **历史事故逐帧诊断:选中一条事故之后,可以在「同一个诊断上下文」里逐帧回看、看那一帧的耗时分解与真实证据**:「取证明细」现在一次只服务**一个**上下文 —— `live`(实时帧环里的当前帧)或 `incident`(选中事故里的一条**历史帧**),由 `state.watch.mode` 决定,**分流只发生在 `detailContext()` 一处**,渲染那侧只读它的返回值。以前帧条 / 翻帧 / 饼图吃实时 `pickedLoaf()`,而事故卡片与回放吃 `selectedIncident()`,于是"上面是历史事故、下面是另一条实时帧的编号与耗时"必然出现 —— 现在实时与历史**共用视觉组件**(`ringBlock` / `scriptBreakdownRows` / `evidenceBlock` / `crossBlock`),但**不共用一个错误的上下文**。详情区第一行是上下文标题(`实时监控 · 当前帧` / `历史事故 i12 · 历史帧 3/305`),折叠组标题带上事故 id,事故卡片上按「看这一帧」进历史、按「回到实时」回实时;切换**只切数据源**,实时采集与另一侧的选中都不动。
70
+ - **历史帧时间轴:可点的导航器,不是装饰性图表**(`replayColumns` / `replayCellTitle`)。事故窗口按**时间**切成 240 列,每列取该列里**最差的那一帧**,并把**那一帧的引用**留在格子里 —— 点击 / 选中 / 高亮全部映射回 `incident.frames` 里真实存在的记录(断言:每个非空格子的 `frame` 都在原数组里,不合成帧);整段 rAF 被挂起 / 采样缺失的地方**留白**,不补桩。档位与实时帧条共用 `sparkLevel` 与 `SPARK_COLORS`,选中帧是**一条蓝色竖线**(`--dsw-alias-state-business-primary`)。240 列把"一条 13 秒窗口几百帧"的绘制量钉在 ≤240 个 DOM 节点上。
71
+ - **四个方向的逐帧导航 + 真实禁用态**(`incidentFrameNav` / `incidentFrameStep` / `incidentFrameBadStep`):`上一帧 / 下一帧` 按**时间序**走(与帧条方向一致),`上一异常帧 / 下一异常帧` 按守夜触发线(`PICK_MIN_MS`)筛;没有下一条就**禁用**按钮,不留"点了没反应"的动作。选中帧的**序号 / 耗时 / 档位文字**跟着更新。
72
+ - **一帧的分解与证据只吃那一帧**:与选中帧**时间重叠**的 `incident.loaf`(多条取最重)提供时间分解与脚本证据;这一帧没有 LoAF 就写「这一帧没有长动画帧记录」,**不拿最差那一帧、更不拿实时帧顶**;脚本证据退回**事故窗口内**切片时块首写明"不是这一帧的";**与这一帧重叠的 Long Task** 单列一块(owner / component / 来源),窗口内总条数当分母。这一帧的脚本聚合走与实时同一套 `tallyScripts`,但 `pkg` / `pkgVia` **优先用捕获时存下来的** —— 刷新之后当前会话的 sourcemap 缓存是空的,现算会把历史事故的包名全算成"未定名"。
73
+ - **归因状态收敛成三档**(`incidentVerdict` + `verdictColor`):**已确认**(有 JS 阻塞证据且窗口里定到了包,并写明"这只缩小到哪个包,不等于已定位根因")/ **可疑**(有 JS 证据但没定到包;或满足 `suspectedThrottle` 的检测条件)/ **未归因**(给的是校准过的那句:未观察到足以解释该帧的 JS 阻塞证据;请结合前后帧和时间间隙检查 rAF 挂起或浏览器节流)。档位**文字 + 档位色**两样一起给,面板与「复制摘要」读同一个函数。
74
+
75
+ ### 修改
76
+
77
+ - **诊断上下文落盘(`LS_WATCHUI`,只存 `mode` / `selId` / `frameT` / `scope`,不存数据)**:刷新之后**仍然停在同一条事故的同一帧**;落盘的事故 id 或帧 `t` 找不到就退回自动,没有事故就退回实时。实时那一侧的 `pickT` 在切换上下文时**不动**,切回来还是原来那条。
78
+ - **`jsNo` 不再把可能性写成结论**:文案从「无(更像 rAF 被节流)」改成「无(longtask/LoAF 都不与它重叠)」;"更像 rAF 挂起 / 浏览器节流"只作为**可疑**那一档的说明出现,而且只有 `suspectedThrottle` 真的成立时才提。
79
+ - **零证据那一行的分母范围按上下文说**:历史帧 / 本帧 / 窗口内三档(`evidenceZeroText` 读 `scopeKind`),不再一律写"窗口内";事故窗口那一支的交叉表也给自己的范围说明(`crossIncidentHint`)。
80
+ - **面板补齐控件态样式**:根节点加 `id="dsh-perf-hud-root"`,用一条**纯 CSS**(`installStyle()`)给按钮补 hover / focus-visible / disabled —— 没有 per-element 鼠标状态(那条禁令见 AGENTS §7)。行内样式优先,所以这三条规则用 `!important`。视觉上补齐 8px 圆角卡片、次级背景 `--dsw-alias-bg-layer-2`、18px/600 的"核心耗时"与等宽数字,颜色全部走既有 theme token。
81
+
82
+ ### 修复
83
+
84
+ - **`h('div', …, hint ?? t('crossHint')})` 那处交叉表范围提示的括号结构**(改 `crossBlock` 参数时引入,`node --check` 当场拦下)。记一笔是因为它正是 AGENTS §8 第 14 条那类"批量替换看起来对、其实断了结构"的同类风险。
85
+ - **`incident.frames` 里的 `gap` 以前会被当成普通帧参与序号与翻帧**:现在 `gap === true` 明确不是帧(整段 rAF 被挂起),不参与序号 / 选中 / 翻帧,只在帧条上留白。
86
+
87
+
88
+ ### 测试
89
+ - 断言 **591 → 603**(logic 541 → 553):ZIP 一组 11 条 —— CRC32 标准向量(空串 0 / `'123456789'` = 0xCBF43926 / `'a'` = 0xE8B7BE43)、UTF-8 按字节算长度、DOS 时间戳年份下限、本地头与 EOCD 签名、EOCD 里的条目数与中央目录偏移、本地头 CRC 与内容字节对得上、压缩方式必须是 0、体积 = 内容之和 + 目录开销、空清单也给合法 zip、以及"导出真的走一个 zip 一次下载"的接线断言。
90
+ - `tools/check-counts.mjs` 真页面新增 **真实字节验收**:先等 `watch().pending === null` 再手动标记一条**带备注**的事故(否则会被并进正在等的那一次,样本就没备注),把页面里生成的 zip 落盘交给**系统 `unzip -t`** 判(`No errors detected`)、`unzip -l` 核四份文件、`unzip -p` 逐份读回来核内容:AI 包里 `incident.note === null` + `noteDropped: true` + `summary.md` 写"备注:已置空"且两个文件都不含原文;完整包保留备注正文、但里面粘的 `token=` 已被脱成 `<redacted>`;两个包大小不同(差的正是那段自由文本)。现场:`ai 55604B / full 55896B · unzip -t 通过 · 四份文件齐全`。
91
+ - 断言 **574 → 591**(logic 524 → 541):最近 vs 最差(时间最新那条 / 空清单给 null / 主界面结构上不碰 `selectedIncident`)、人话原因(四种 kind 都有分类、不出现 `@`/`dsh-`/`.js`/数字、"未知"那支只写"缺少 JS 证据"不写节流)、两条导出路径(AI 包备注置空且 `summary.md` 也不带 / 完整导出保留备注但裸 token 变占位 / 预览 `notes` 两种取值 / 文件清单一致)、趋势行(人话不含包名、原始线索才含、没有重复线索给 null)。
92
+ - `tools/check-counts.mjs` 真页面加了一组:主界面默认**不出现包名**、展开「为什么这样判断?」之后才有支持 / 限制、点「导出诊断报告」**先出预览**(文件 / 备注已置空 / 生成与取消 / 取消后收起),以及**每个视图的正文里都不许出现词典键名**(按标识符边界比,避免 `watchui` 里的 `watch` 假阳性)。
93
+ - 断言 **561 → 574** 条(logic 511 → 524):视图模型与主界面 13 条 —— 监测不可用不许说"运行流畅"、三个档位图标不同、秒数写法、`frameLevel` 与判色同源(fps 20 → stall/bad;最差帧 130ms 也 stall)、趋势三个数的口径与长度预算 ≤ 60、只出现一次不算"重复线索"、空窗口给人话不显示零值。
94
+ - `tools/check-incident.mjs` 跟着改成方案 1 的走法,并把**视图切换**也纳入验收:主界面「查看这次卡顿」→ 卡顿详情(独立一屏,有「← 返回」)→ 逐帧回放照旧 → 刷新后仍停在同一帧 → 「← 返回」回主界面(不再挂返回按钮)→ 开发者详情 5 个标签页 → 「回到实时」→ 设置页三块齐全且深录仍默认关。**ALL PASS**。
95
+ - 断言 **539 → 561** 条(logic 489 → 511):能力矩阵(叶子形状与枚举、三组齐全与 unobserved、没插桩时 DOM 写入 off 且理由写明、element timing 恒 unsupported、`capabilityMissing` 由它推且前缀齐全)+ 候选原因(最多 3 条、每条三个非空字段、限制里必有"不等于根因"、覆盖率不到 100% 时写明还剩几条、宿主只在 p99 偏高时出现、`worstFrameJs==='none'` 时只能"证据不足"或"更像…"、空 id 给空数组)+ 诊断包(四文件顺序、manifest 字段、凭据进不去、五段形态、每条假设的出处只能是三档之一、预览的包含 / 排除 / 脱敏、`dropFreeText` 在 manifest 与 incident 两处都留标记、不存在的 id 返回 null)。
96
+
97
+ - 离线断言 **497 → 539** 条(attribution 11 / logic **489** / host 39):捕获计数的口径(窗口边界按分钟桶切、`coveredFrom/To` 照实给、日汇总与当日分钟桶同一口径、24h 外裁剪有计数、`worstMs` 取 max 不求和、跨刷新读回、坏 JSON 与形状不对的桶不静默变 0、`bumpTally` 认不出的 kind 不静默记错、起过捕获没切成现场单列 `unsettled`、清事故不动计数桶、药丸文案不再拿留存份数当次数)+ 导出脱敏(丢查询串 / 留 rev / 只换值不换键 / `~` 降级 / UA 降级 / 数组走脚本规则 / `dropFreeText` 留 `noteDropped` 标记)。`ci.yml` 的 `-eq` 门禁与 README / README_EN / AGENTS / REVIEW 的数字同步。
98
+ - **新增 `tools/check-counts.mjs`(真页面验收器,和 `check-panel` 一样不是离线门禁)**:用 `history.replaceState` 把凭据**塞进真的 `location`** 再断言导出件里一个都不剩(离线盒子的 `location` 是桩,这条在那里是空断言);清桶 → 手动标记一次真捕获 → 断言 `detected = triggered`、`detected !== captured + coalesced`、`kept ≤ detected`、落盘两拍之后读得回来;刷新后计数还在;折叠药丸上出现「检测 N 次」。**ALL PASS**,现场读数:`检测到 3(triggered 3 · 切成现场 2 · 同串触发 29)· 已保存现场 2 · 丢失 1` —— 旧口径会把这次记成 32 次。
99
+ ### 测试
100
+
101
+ - **新增 `tools/check-incident.mjs`(真页面验收器,和 `check-panel` 一样不是离线门禁)**:清事故 → 同步烧 400ms 造一条真达线帧 → 等守夜自动切片 → 按「看这一帧」进历史上下文 → 断言上下文标题 / 时间轴在场 / 点柱子换帧 / 下一帧上一帧 / 下一异常帧落在达线帧上 / 走到最后一帧后「下一帧」禁用 / **刷新后仍停在同一帧** / 「回到实时」把实时帧条换回来 / 全程 `errors: 0`。**ALL PASS**。
102
+ - 离线断言 **458 → 490** 条(attribution 11 / logic **440** / host 39):上下文隔离(历史那一支不吃实时帧、实时那一支不吃事故)、历史帧列表跳过 gap、选中帧 `t` 找不到就退回自动、四个方向的导航与禁用态、帧条"每列取最差且一定是真实帧 + 缺口留白 + 900 帧只画 ≤240 列"、包名用存下来的(刷新后仍定得到名)、这一帧没有 LoAF 时证据退回窗口切片并标 `missing`、long task 只给重叠的、三档归因状态、`LS_WATCHUI` 落盘/读回与失效退回,以及"`frameNearestIncident` 这条就近定位老路已删掉";`.github/workflows/ci.yml` 的 `-eq 490` 门禁与两处 `Offline suites (…)` 标题、README / README_EN / AGENTS / REVIEW 里的数字同步改。
103
+ - 随后再补 **490 → 494** 条(logic 440 → 444):一条**零证据分母范围**的口径断言(历史帧 / 本帧 / 窗口内三档,不许一律写"窗口内"),以及**三条结构性静态检查** —— ① 上下文只在 `detailContext()` 一处分流;② `incidentDetail` 的函数体里不出现任何实时读数(`pickedLoaf` / `scriptPkgTally` / `framePkgTally` / `worstSecond` / `recorderText` / `unexplainedFrame` / `frameRing`);③ 新加的渲染辅助函数(定义在 `function PerfHud(` **之前**,旧的"渲染区裸调用"检查盖不到)单独跑同一条未声明引用分析。后两条的**牙齿**是用副本验过的:往 `incidentDetail` 里塞一个 `pickedLoaf()`、往 `timelineBlock` 里塞一个未声明的裸调用,断言都会 FAIL。`ci.yml` / `release.yml` 的 `-eq` 门禁与 README / README_EN / AGENTS / REVIEW 里的总数随之同步到 **494**。
104
+ - 再补 **494 → 497** 条(logic 444 → 447):**证据块底下那一行的正文/悬停分离**("可疑脚本"底下以前把口径与缺口两整段摊在正文里,面板是纯文本渲染,`**` 与反引号原样显示 —— 现在正文只留分母 + 一句悬停提示,全文进 `evidenceFooterTitle()` 的 `title`,与「复制摘要」共用同一份措辞),以及两条**词典级**断言:① 正文文案不许带 markdown 记号(`**` / 反引号),带记号的一律得是白名单里"只进悬停"的那 20 个键;② 那份白名单不许有死键。
105
+ - 真页面四件套:`check-panel` / `check-storage` / `check-interaction` / `check-incident` **ALL PASS**;另外用 CDP 真实鼠标扫过展开的面板 644 个点,断言条目仍活着、`errors: 0`、悬停边框解析到交互蓝 token —— 这是纯 CSS,没有任何 JS 鼠标状态。
106
+
107
+ ### 工具
108
+ - **记录器自己的代价可以量了(`tools/measure-cost.mjs`)**:分 `off` / `watch` / `deep` 三档,各测空闲期帧节奏(帧间隔 p50 / p95 / max)、堆增量(每档先 `clear()` 再测,含"这一档又攒起来"的驻留成本)与 `setTimeout` 微基准(6000 次的耗时;**深录档与另外两档的比值就是调度点包装的逐调用成本**)。为什么不能再用"驱动一次同步提交、比两次提交谁慢"来量:同步提交期间 rAF 与两个观察器**根本插不进来**,而 HUD 关着那一档连 `stress()` 组件都不存在(它只在 HUD 渲染时存在)—— 两档量的不是同一件事。**守夜内部无法再细分**(`state.watch.on` 与总开关联动,`watchStatus()` 返回的是快照副本,写不进去),所以守夜档的代价是合计的;这是代码的结构缺口,不是测量偷懒。它要真浏览器 + 本机 `dsh web`,不在 `npm test` 与 CI 里。
109
+
110
+ ### 文档
111
+ - **README / README_EN 的界面说明跟上新版 UI**:补「四屏一眼看完」的九张截图(主界面 / 卡顿详情 / 设置 / 开发者详情五个标签页 / 折叠药丸)、五步操作路径(展开 → 看那一次卡顿 → 看技术数据 → 交给 AI → 本机排查),并写明两条导出路径的差别。截图由新脚本 `tools/shots.mjs` 生成:每张图**先把视图状态写死再刷新**,所以图与代码里的那一屏一一对应,改完重跑即全量更新;它和 `check-*.mjs` 一样不在离线门禁里。截图放 `docs/images/`,**不进** `package.json#files`(GitHub 上的 README 用相对路径就够;真要以 npm 页面为主再把它加进白名单 —— 这条取舍写在这里,免得以后当成遗漏)。
112
+
113
+
114
+ - **首发机制改准(staged publishing,不是直接 `npm publish`)**:0.4.0 实际是先 `npm stage publish`(registry 见状建了占位版本 `0.0.0-stage` 占住包名),再由维护者用 2FA **approve** 上线的 —— 证据是 registry 的 `gitHead` 等于当时的 `main`、`fileCount` 18、线上 tarball 与本机 `npm pack` 逐文件 sha256 全等(字节 shasum 不同只因打包用的 npm 版本不同:线上 11.17.0 / 本机 11.12.1)。REVIEW §3.1 与 AGENTS §10/§11 按这份证据改写,并记下 Trusted Publisher 的 **Allowed actions** 决定以后是否需要每版人工 approve(`npm stage publish` 永远允许;直接 `npm publish` 要显式勾选)。上一版文档写"本机 `npm publish` 首发"是错的。
115
+
5
116
  ## [0.4.0] - 2026-10-11
6
117
 
7
118
  ### 新增
@@ -207,5 +318,6 @@
207
318
  [AGENTS.md](AGENTS.md) §0.1,`LANDSCAPE.md`(生态与撞车矩阵)并进 [AGENTS.md](AGENTS.md) §12 —— 三份带日期的
208
319
  记录文档删除,git 历史里还在
209
320
 
321
+ [0.5.0]: https://github.com/yanzhaohui1999/dsh-perf-hud/releases/tag/v0.5.0
210
322
  [0.4.0]: https://github.com/yanzhaohui1999/dsh-perf-hud/releases/tag/v0.4.0
211
323
  [0.2.0]: https://github.com/yanzhaohui1999/dsh-perf-hud/releases/tag/v0.2.0