dsh-perf-hud 0.0.0-stage → 0.4.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 ADDED
@@ -0,0 +1,416 @@
1
+ # dsh-perf-hud 仓库规则(AGENTS)
2
+
3
+ > 本文含**项目全局开发规则 + 源码级机制 + 方向取舍**(面向贡献者与 agent)。
4
+ > 装什么怎么用、读数怎么看 → [README.md](README.md);发布门槛与验收证据 → [REVIEW.md](REVIEW.md);
5
+ > 变更历史 → [CHANGELOG.md](CHANGELOG.md)。
6
+
7
+ ---
8
+
9
+ ## 0. 项目边界
10
+
11
+ **纯客户端性能 HUD**:一个浏览器半(`client.js`,右下角浮层)+ 一个宿主半(`index.js`,只提供事件循环与 bundle 代理两个探针)。
12
+ 它**测量别人的性能**,所以它自己必须便宜、可关、可还原 —— 这条决定了下面几乎所有规则。
13
+
14
+ ### 0.1 定位:行车记录仪 + 事故包(2026-10-07 起)
15
+
16
+ 要回答的问题只有一个:**"我今天用下来,哪一段最卡、是谁"**。卡顿是偶发的,指望人盯着面板就等于
17
+ 永远抓不到 —— 所以主体不是"仪表盘",而是**默认常开、事后能回看的记录器**:
18
+
19
+ | 模式 | 什么时候 | 做什么 | 成本 |
20
+ |---|---|---|---|
21
+ | **守夜**(默认常开) | 一直 | 帧级环形缓冲 + LoAF 观察器;自动留存**最差 10 次 + 每天最差 1 条**(最多 7 天)并落盘 | 零插桩:只挂本来就在跑的 rAF 与两个 `PerformanceObserver`(LoAF + 交互计时) |
22
+ | **现场**(展开面板) | 想看一眼 | 概览(卡不卡 + 最差那条是谁)/ 回调归因 Top3 / 守夜那行;其余五组折叠(`state.groups` = `detail` / `cross` / `dom` / `host` / `tools`) | 可忽略 |
23
+ | **深录**(显式) | 要抓全 | 才打开原型插桩(DOM 写入 1/10 采样 + 调度点抓栈)与「精确归因」 | 有代价,必须显式开 |
24
+
25
+ > ✅ **这张表现在是真的了(2026-10-08,D1)**:在那之前插桩没有独立开关 —— `start()` 一跑就把 `setTimeout/setInterval/rAF` 与 `appendChild/insertBefore/setAttribute` 全包上,「守夜 = 零插桩」只是文档上的话。现在插桩被拆进 `applyProbeState()`,只在 `state.deep`(标题栏「深录」)打开时装,深录同时管精确归因(两者是「要抓全」的同一件事)。实测:默认 `instrumented: false`,点开 → `true`,点回去 → `false`,面板 `errors` 全程 0。
26
+
27
+ **明确不做**(生态里已经有人做了;重复做比谁都不做更糟,详见 §12):
28
+
29
+ - 治理类特性(写盘频控、列表 publish gate、`content-visibility` 补丁)—— `@linxin666/dsh-perf` 的地盘;
30
+ - bisection 工作流(隔离 profile + 重启 + 配对 CI/Hedges' g)—— `dsh-performance-guard` 的地盘;
31
+ - 宿主侧每插件 CPU / 堆 / 磁盘 / 进程树 —— `dsh-perf-lens` 的地盘(它已有常驻 JSONL 与进程树)。
32
+
33
+ **仍然不做的事**:
34
+
35
+ - 不碰 DSH 源码,不注册服务,不监听 `session/event`(除非用户显式开 `watchEvents`)。
36
+ - 不写任何自己的持久化状态到 profile;面板状态只进 `localStorage`。
37
+ - 不改 `cordis.patch.yml`(它只负责把本包挂进组合),不改 profile 的持久化配置行。
38
+
39
+ ---
40
+
41
+ ## 1. 仓库硬约束
42
+
43
+ - **`cordis.patch.yml` 只读**:它是"把本插件挂进 profile"的一行声明,内容变化会触发组合 reconcile,**不要把它当配置面**。插件行为一律靠 `package.json` 的 `dsh.*` 与代码里的开关。
44
+ - **禁止在没有 `confirm: true` 的情况下写 bundle 状态**:宿主半的 `setBundle` 会写 profile 的持久化选择,是唯一一个"插件会改用户环境"的操作。它必须:① 显式 `confirm`;② 拒绝改 `dsh-perf-hud` 自己与 `@deepseek-ai/dsh-session-persistence-jsonl`;③ 改动前后逐项核验并恢复。
45
+ - **宿主半不许改宿主任何原型**(离线套件有断言钉住);客户端半的原型插桩(见 §5)必须是**可还原**的,`disable()` 后 `W.setTimeout === natives.setTimeout` 等断言必须成立。
46
+ - **不许假装成功**:宿主回执、map 解析状态、事件测量是否装上,一律照实显示并写进导出件(`application` / `mapStates` / `eventWatch.installed+reason` 都是这个原则的产物)。
47
+ - **不凭记忆写 API**:本项目的每一步(路由注册形态、事件名、sourcemap 形态、DOM 钩子)都来自当场读源码或实测;记忆里"应该如此"的东西(`data-dsh-plugin`、包 `Context.emit`)都被证伪过 —— 见 §6 与 §8。
48
+
49
+ ---
50
+
51
+ ## 2. 环境与改动生效方式(决定你能不能验证)
52
+
53
+ 基线环境:DSH **`0.2.0-rc.2`**,profile 目录 `~/.dsh/profiles/web`,本仓库以 `link:` 装进该 profile
54
+ (`dsh plugin --profile web add /Users/y/workspace/dsh-perf-hud`)—— **宿主加载的就是这个检出**,所以下面这张表直接决定"你改的东西什么时候真的在跑":
55
+
56
+ | 改动 | 生效方式 |
57
+ |---|---|
58
+ | `client.js`(客户端半) | `dsh-client-hmr` 轮询 mtime/size 自动热载;**刷新页面**最稳 |
59
+ | `index.js`(宿主半) | **必须重启宿主服务**(本机 `launchctl kickstart -k gui/$(id -u)/ai.dsh.web`) |
60
+ | `package.json` 的 `dsh.client` / `dsh.bundle` | 需要重新扫描(重装或重启) |
61
+ | 纯文档(`*.md`) | 无需任何操作 |
62
+
63
+ ⚠️ **重启会杀死正在跑这个插件的会话**:agent 的 bash 工具进程就在 `dsh web` 进程内(实测进程链)。改宿主半后要重启时,把重启排在报告之后(`nohup bash -c 'sleep N; launchctl kickstart …' &`)或交给用户执行,**不要在会话中途重启**。
64
+
65
+ 为什么宿主插件没有文件监听:本 profile 的 `dsh-hmr` `config.root` 是 `[]`,它只在 bundle 列表或 patch 文件**内容**变化时 reconcile —— 这是机制原因,不是偷懒。
66
+
67
+ 两条容易踩的环境事实:
68
+
69
+ - `dsh --profile web --dump-config` 会**写** `~/.dsh/profiles/web/cordis.yml`(`prepareProfile`),因此需要 profile 写权限;沙箱下会以 `EPERM: open '…/cordis.yml'` 失败 —— 那是权限,不是命令坏了。
70
+ - 如果浏览器在另一台机器上、经隧道访问 `127.0.0.1:3080`,面板里的**链路 RTT 高是真实的**:隧道本身的 RTT p50 可以是数十到数百毫秒,而本机 curl 同一路由只要 ~3ms。别把它当 bug —— 面板为此有「疑似链路」判据和「链路已排除」结论。
71
+
72
+ ### 2.1 并行开发:worktree 放 `.worktrees/`
73
+
74
+ 需要同时开两条互不干扰的改动、或让主检出始终保持可运行版本时,用 git worktree,目录固定在 `.worktrees/<slug>/`(已写进 `.gitignore`,不会进 git):
75
+
76
+ ```sh
77
+ git worktree add .worktrees/i18n -b fix/i18n-fallback # 带新分支的槽位
78
+ git worktree add --detach .worktrees/scratch HEAD # 只想跑一遍测试
79
+ git worktree list
80
+ git worktree remove .worktrees/i18n # 收工;分支若不要了再 git branch -d
81
+ ```
82
+
83
+ **本仓库零运行时依赖、无构建步骤,所以 worktree 里不需要 `pnpm install`,建好就能 `npm test` / `node tests/*.mjs`**(实测:三套件在 worktree 里直接全绿)。
84
+
85
+ ⚠️ **但宿主加载的不是 worktree**:profile 里本包是 `link:/Users/y/workspace/dsh-perf-hud`(主检出),`dsh-client-hmr` 轮询的也是那个路径下的文件。所以在 worktree 里改 `client.js`,**浏览器里看不到**。要在浏览器里验证 worktree 的改动,只有三条路:
86
+
87
+ 1. **改完先合回主检出**(推荐,最省事);
88
+ 2. 临时把 profile 指过去:`dsh plugin --profile web add "$PWD/.worktrees/i18n"`,验完再指回主检出 —— 注意这改的是 **profile 的持久化依赖行**,验完务必指回来;
89
+ 3. 干脆在主检出上改。
90
+
91
+ 单线程开发(目前的模式)不需要 worktree;它解决的是"两条改动同时开着"和"主检出要保持能跑"这两件事。参考仓 `dsh-better-sidebar` 用同一套约定(它的 `.worktrees/` 也是走 `.gitignore`;注意它 AGENTS.md 里写的是 `.git/info/exclude`,实测那条不在 exclude 文件里 —— 用 gitignore 才对,exclude 不会被 clone 带走)。
92
+
93
+ ### 2.2 没有浏览器时怎么验证
94
+
95
+ 1. **cookie 三步**(完整步骤见 `~/.dsh/dsh-api.md`):从宿主启动日志里取 `token=…` → `curl -H "Host: 127.0.0.1:3080" "http://127.0.0.1:3080/?token=$TOKEN"` 拿 `Set-Cookie` → 之后 `/api/*` 都带这个 cookie。**token 每次重启都换**,所以换 cookie 必须在重启**之后**做(踩过一次:拿旧 token 一路 401)。
96
+ 2. **401 与 404 的区别**:`/api/dsh-perf-hud/host` 无 cookie → **401**;路由根本没注册(宿主半未加载)→ **404**。线上脚本用 404 判定"宿主半还没加载"(退出码 3),与"实现有 bug"区分开。
97
+ 3. **Cordis inspect**(不需要 cookie,走已连接的页面):
98
+ - Client `Slots` → `listSubTree(root: 'shell.overlay')`:能看到本插件条目,**`active: false` 就是"渲染抛错被外壳停用"的硬证据**;
99
+ - Client `Builtin.listBuiltins`:动态客户端半能用的符号(`ctx` / `React` / `host` / `styles` / `console`)。
100
+ 4. **重启后要跑的验证器必须起在独立进程组**:它会被重启的 launchd 进程组一起带走,所以用 `setsid`(`python3 -c "os.fork(); os.setsid(); os.execv(...)"`)启动。原来那个封装脚本(`tests/verify-after-restart.sh`)写死了本机 node 与仓库路径,已删 —— 教训留在这一条里,要用时照上面重写几行即可。
101
+ 5. **查工作区别走 `workspace/list`**(该端点 404):读 `~/.dsh/storages/workspace.json` 的 `tables.workspaces` + `global.workspaceIds`;细节见 `~/.dsh/dsh-api.md`。
102
+ 6. **改完面板 / `apply()` / `start()` 路径之后跑哪个**(§8 第 14 条那条规矩的可执行版本;三个脚本自己起 headless Chrome,零依赖,token 从 `~/Library/Logs/ai.dsh.web.log` 取,共用底座 `tools/cdp.mjs`):
103
+
104
+ ```sh
105
+ node tools/check-panel.mjs # 面板渲染 0 错误 + 目标元素在场(默认断言面板标题)
106
+ node tools/check-panel.mjs --expr="!!document.querySelector('svg[viewBox=\"0 0 60 18\"]')"
107
+ node tools/check-storage.mjs # 写 localStorage → 重新加载 → 读回(落盘/跨会话基线)
108
+ node tools/check-interaction.mjs # CDP 发真实输入 → 验 event timing 真的交付
109
+ ```
110
+
111
+ ⚠️ **它们不是离线门禁**:要真浏览器 + 本机跑着 `dsh web`,所以 `npm test` 与 CI 里**没有**它们(CI 只对 `tools/*.mjs` 做 `node --check`)。`check-panel.mjs` 挡的就是"三套件全绿、浏览器里插件激活或渲染失败"那类事故,所以它跑不过就别提交。
112
+
113
+ ---
114
+
115
+ ## 3. 测试与门禁
116
+
117
+ **每次改完必须跑三套件**(改文案还要顺带跑长度预算与单位一致性断言):
118
+
119
+ ```sh
120
+ 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 安全线)
123
+ ```
124
+
125
+ 一条命令跑完:`npm test`。线上(先按 §2.2 换 cookie):
126
+
127
+ ```sh
128
+ DSH_COOKIE=… node tests/live-sourcemap.mjs # 真实 indexed sourcemap 的分段与包名
129
+ DSH_COOKIE=… node tests/live-host.mjs # 真实路由;退出码 3 = 宿主半还没重启加载
130
+ ```
131
+
132
+ 测试写法约定:
133
+
134
+ - **测线上那份代码**:`logic.mjs` 用 `new Function(source + 'return {…}')` 把 `client.js` 里的真函数抽进最小环境,新增函数想被测就加进 `pieces` 列表 + 在桩里补它用到的键。**不要另写一份等价实现来测。**
135
+ - ⚠️ **`logic.mjs` 里有三个沙箱**(纯函数盒 / 导出盒 `newPieces` / 守夜盒 `watchBox`),各有自己的 `pieces` 列表与桩。往 `client.js` 加一个函数或常量之后,要把它加进**每一个用到它的盒子** —— 漏一个不会立刻报错,而是等那个盒子跑起来才 `ReferenceError`(实测:`WATCH_EVENT_MS` 只在导出盒里被 `snapshot()` 引用,守夜盒里反而先绿;`pruneIncidents` 漏在守夜盒里时,前面的断言全过、最后一个盒子才炸)。守夜盒的 `state` 是**手写桩**,新状态位还要在桩里补上。
136
+ - **口径有歧义就加断言,不要加注释**:注释不能防回归,断言能。
137
+ - **静态检查必须有牙齿**:`logic.mjs` 顶部两条(① `h(Uppercase…)` 引用的组件必须声明过;② 面板渲染区里的裸函数调用必须声明过)是"整段替换吞掉相邻声明"事故的回归测试(§8 第 7 条);验证它的办法是把声明删掉看它是否报出名字。
138
+ - **宿主半可离线真跑**:`tests/host.mjs` 用假 `ctx`(含 `connection.fetch.register` / `pluginManager` / `ctx.on`)驱动真实 `index.js`,断言路由、事件循环升温、confirm 安全线。
139
+ - 断言总数写进 [README.md](README.md) 与 [REVIEW.md](REVIEW.md),**改数字要同步改文档**;CI 里有一条门禁专门核这个。
140
+
141
+ ---
142
+
143
+ ## 4. 提交规范
144
+
145
+ Conventional Commits,描述用中文:
146
+
147
+ ```
148
+ <type>(<scope>): <描述>
149
+ ```
150
+
151
+ | type | 用于 |
152
+ |---|---|
153
+ | `feat` | 新增读数 / 新增面板能力 / 新增导出段 |
154
+ | `fix` | 读数错误、口径不一致、误导读数、崩溃 |
155
+ | `refactor` | 不改外部行为的结构调整(抽纯函数以便单测也算) |
156
+ | `style` | 纯排版(面板布局、文案断行) |
157
+ | `docs` | 文档 |
158
+ | `test` | 只动测试 |
159
+ | `chore` | 构建、脚本、杂项 |
160
+
161
+ scope 用固定这几个:`client` / `host` / `attribution` / `panel` / `isolation` / `i18n` / `docs` / `tests`。
162
+
163
+ - 标题 ≤72 字符,**一句话说清"改了什么",不写"为什么"**;为什么放正文。
164
+ - 正文值得写长:本项目的正文是**设计决策档案**(为什么放弃包 `Context.emit`、为什么 resolution 收到 1ms、哪条路已证伪)。改动涉及取舍时,把"试过什么、怎么证伪的"写进去。
165
+ - 正文里不出现"用户问…/用户嫌…"这类对话转述 —— 直接写现象与结论。
166
+
167
+ ---
168
+
169
+ ## 5. 代码约定
170
+
171
+ - **面板只用 theme token**(`--dsw-alias-*`),不 require 任何 Harness Client 包;颜色只用 token,字面色仅限插画。
172
+ - **整数 px、整数字重**:`11.5px` / `fontWeight: 650` 在 125%~150% 显示缩放下会触发合成加粗 + 重新栅格化,观感发糊。用 `12px` / `700`。
173
+ - **标签不收缩、数值换行**:行布局是 `label { flex: 0 0 auto; nowrap }` + `value { flex: 1 1 auto; textAlign: right }`;反过来会把标签挤成"一字一行"(实测「宿主事件到达」竖排过)。
174
+ - **长文本用块式 + 主动断行**:可能超一行的行(概览、折叠摘要)用"标签独占一行 + 值左对齐 + `white-space: pre-line` 自己给 `\n`",不要赌浏览器宽度(曾断在 `最多` 后面,把 `ChatView 22×` 甩到下一行)。
175
+ - **能不说就不说**:「最多」与「最差那条」名字相同时整行不显示(它只是把同一句话说两遍);说明文字放 `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 条)。
177
+ - **单位一致性**:同一张表里不许一处给 `/s`、另一处给累计;任何"和邻居不同单位"的读数都要有断言(曾出现"上面是 /s、下面给累计")。
178
+ - **折叠组默认不做昂贵分析,现在由 `group()` 的 thunk 保证**:`group(key, label, summary, ...children)` 支持"唯一一个 child 且是个函数"的形式,折叠时**连函数体都不进**;五个调用点(`detail` / `cross` / `dom` / `host` / `tools`)全传 thunk,所以「取证明细」收起时脚本按包 / 交叉表 / 帧条一次都不算。代价要记住:`cross` 那一层仍嵌套在「取证明细」的 thunk 里 —— 一展开「取证明细」,调用方就先构造 `crossTable(scriptPkgTally())` 与 `kids` 数组(4×5 格,代价低),所以窗口聚合那份缓存不能省。
179
+ - **可点 / 不可点的界线(不要做假按钮)**:可点 = 「按来源 / 按包」小表的**具名行**(→ `copyText` 复制「名字 + 毫秒」,成功/失败都照实写进 `state.copyNote`)、工具箱事故清单行(→ 选中)、帧条**有 LoAF 的格子**(→ 钉住那一帧)、组头(→ 折叠)、**可疑脚本的每一行**(→ 复制那条证据行);**不可点** = 堆叠条的**色段**本身(`cursor` 是 `default`)、「其余」那一行(它是合计数,没有单个名字可复制)。
180
+ - **按钮按需出现,不留"点了没反应"的动作**:「全部折叠」只在**有组开着**时渲染(`anyGroupOpen()`),「恢复默认布局」只在 `layoutDirty()` 为真时渲染(有组开着 / 翻帧被钉住 / 手选了事故 / `scope` 不是 `worst` / 挂着一次性说明);两者都无意义时整行不渲染。新加"布局类"按钮前先问:默认状态下点它会发生什么?什么都不会发生就别显示。
181
+ - **i18n 词典成对改**:`DICT.zh` 与 `DICT.en` 同时加键(现在中英各 **239** 键)—— 两条断言钉住这件事(logic.mjs):**键集合必须一致**、**每个键都得有人引用**(撤下界面时留下的死键会被报出来;死键那条只认字面量 `t('…')` / `translate('…')`,真要加动态取键得先放宽它)。**兜底策略跟文档语言**:locale 服务不在时按 `document.documentElement.lang`(取不到退 `navigator.language`)选那本字典,不是中文就用英文 —— 写死中文会让英文用户看到中文,简单反转(`DICT.en ?? key`)又会让中文用户看到英文,两种都错。
182
+ - **插桩必须可还原**:包装 `setTimeout/setInterval/rAF` 与 `appendChild/insertBefore/setAttribute` 时,保存原生引用、只在"当前仍是我们那个 wrapper"时才还原(避免踩掉别人的包装)。
183
+ - **改 `client.js` 一律用带锚点的小批次**:一次替换一个可唯一匹配的片段,失败即整批作废。本项目有**两次**"整段替换吞掉相邻声明"的事故(§8 第 7 条)。
184
+
185
+ ---
186
+
187
+ ## 6. 归因机制(源码级事实)
188
+
189
+ ### 6.1 脚本 URL 形态与"包名从哪来"
190
+
191
+ | 形态 | 含义 |
192
+ |---|---|
193
+ | `plugins/??<包名>/client.js&rev=<hash>` | 单包组合,**动态装载时一个包一条** |
194
+ | `plugins/<包名>/<分片>.js?rev=<hash>` | 包内分片 |
195
+ | `plugins/??a/client.js,b/client.js&rev=<hash>` | 启动批;本版本**只 preload 不执行** |
196
+
197
+ - **LoAF 的 `scripts[]` 也能定到包名**(2026-10-07):它给的是 `sourceURL` + `sourceCharPosition`,单包 URL 直接从 URL 读出包名;多包 URL 要把字符位置换算成行号,再走同一张分段表。换算需要那段脚本文本(同源 `fetch`,浏览器内存缓存命中),所以按 URL 后台预取、只留行起始偏移(`Int32Array` 级别的开销)。**逐 UTF-16 code unit 数换行**,不能用 code point。实测:一次事故 37/37 条脚本全部定名,归到 `@deepseek-ai/dsh-api-gateway`
198
+ - **合并包里没有 `sourceURL` 边界标记**,所以包名只能靠它自带的 **indexed sourcemap**:`plugins/??a/client.js.map,b/client.js.map&rev=…` → `sections[i].offset.line`(0-based)+ `map.sources[0]` → `/plugins/<包>/…`;V8 的 `CallSite.getLineNumber()` 是 1-based,**换算要减一**。线上实测:58 sections、包名与批次 entries 顺序完全一致、116 处边界断言 0 错。按 `rev` 缓存分段表(`localStorage` 的 `dsh-perf-hud:map:<rev>:<len>`),同一个 rev 只拉一次那份 12MB 的 map。
199
+ - `window.__DSH_BOOT__ = { rev, entries, batches }`(首页内联);批次名取自 `batches[i].phase`(`application` / `bootstrap`),包数 = `batches[i].entries.length`。
200
+ - **`data-dsh-plugin` 在本版本不存在**,不能靠它归因;可用的稳定 DOM 钩子只有:`[data-chat-running]`(流式判定)、`[data-conversation-content]` / `[data-chat-flow]`(消息区)、`[data-row-key]`(会话行的 `parentElement` 当列表容器)、`[data-shell-overlay]`。
201
+ - 精确归因关闭时,合并批次只给一行标签(`application批次(58 包)`),**这是刻意的**,不展开。
202
+
203
+ ### 6.2 四条腿各自的能力边界
204
+
205
+ 面板会**同时给出不一致的两条**,不挑好看的报:
206
+
207
+ | 腿 | 机制 | 擅长 / 局限 |
208
+ |---|---|---|
209
+ | `fiber` | 从被改动节点**往祖先**走最近的具名函数组件 | 擅长增量更新;整棵子树一次性挂载时只能给到容器 |
210
+ | `子树` | 往**子树**走,优先 `alternate === null`(React 标记"本次新挂载") | 补"整棵挂载"空档;有界 BFS ≤200 |
211
+ | `DOM写入` | 采样 `appendChild` / `insertBefore` / `setAttribute`(1/10),在写入点抓栈 + 取组件名 | 提交阶段栈里没有插件帧时的主力;估算值带 `≈` |
212
+ | `包名` | 调度点插桩(`setTimeout` / `setInterval` / `rAF`)与长任务时间重叠 | 定时器 / 动画类;**事件处理器、Promise、微任务不在内** |
213
+
214
+ **交互延迟不走这四条腿**:`event` / `element` timing 是单独一层(守夜档就挂着,不插桩),给的是端到端时长与 `waitMs` / `handlerMs` 的**粗略两段**,只进导出件。所以"事件处理器不在四条腿里"这条依然成立 —— 别把 `context.interactions` 读成帧内拆分(那份拆分页面内没有 API,见 §8)。
215
+
216
+ ### 6.3 宿主外壳名 / 错误边界 / 压缩名
217
+
218
+ - 外壳组件名**不是答案**:`SlotOutlet`(槽位座)、`CenterColumn`(布局列)、`*Boundary` / `*Provider` / `*Context`、错误边界(判据:原型上有 `componentDidCatch` 或 `getDerivedStateFromError`)、生产构建压缩名(大小写 1~2 字母 + 数字,如 `n5` / `Bn`)一律跳过继续找。**这是补丁,不是根治** —— 根治靠上面四条腿交叉。
219
+ - 外壳的 `SlotErrorBoundary`(在 `dsh-client-ui-renderer`)会在条目渲染抛错时 `console.error('slot entry crashed in …')` 并**永久停用该条目**(`active: false`,只能刷新恢复)。所以 `client.js` 自带 `HudBoundary`,把错误降级成一块小面板并把错误原文写进导出件的 `errors` 段。
220
+ - 生产 React **没有** `_debugSource` / `jsxDEV` → **无法**从 fiber 直接拿到组件定义文件;组件名只能靠 `type.displayName ?? type.name`。
221
+
222
+ ### 6.4 宿主侧 API 事实(本插件实际用到并验证过的)
223
+
224
+ - **挂 HTTP 路由的形态**(照抄 `dsh-context`):`ctx.inject(['connection'], c => { const reg = c.get('connection')?.fetch?.register?.bind(…); c.effect(() => reg({ path, methods: ['GET','POST'], requestBody: 'buffered', fetch: handler }), label) })`;handler 收到 Web `Request`,返回 `Response.json(…)`。
225
+ - `pluginManager`:`listBundles()` / `setBundleEnabled(name, enabled)` 是 `@Remote`(客户端可经 `ctx.remote.pluginManager` 调),**宿主半也可以直接调**(本插件就是;隔离实验正是走这条)。两者回执形状相同:`{ changed, application, stage, target, enabled, error… }`。
226
+ - `perf_hooks.monitorEventLoopDelay({ resolution })` 的 **`resolution` 默认 10ms**,会把 p50 顶到 ~10ms 的地板,看不出真实抖动;本项目用 `1`。单位是**纳秒**。宿主半每秒采 **p50 / p99 / max** 三个数下发(`index.js` 取 `histogram.max`),面板那一行照抄三个都给 —— 只给 p99 会把偶发的秒级停顿藏掉;老版本宿主半没有 `max` 字段时**如实只给 p50/p99**,不补 0。
227
+ - 客户端拿宿主数据有两条通道:客户端 `ctx.remote`(本 profile 未接通)与宿主路由 `/api/dsh-perf-hud/host`(已验证)。面板与导出都记 `via`,**不猜通道**。
228
+ - `ctx.on('session/event', (session, event) => …)` 是**公开且可用**的监听方式(与 `dsh-session-persistence-jsonl` 同一个事件);`event.time` 是提交时间戳。我们记的是「提交 → 到达监听器」的延迟,**不是**监听器的 CPU 耗时 —— 面板文案与导出字段(`mode: 'listener'`)都照这个口径写。
229
+ - **插件展示元信息**(Plugins 页那一行)由 `package.json#icon` + `locale/<lang>.json` 的 `meta.title` / `description` 提供。⚠️ 实测(`@deepseek-ai/dsh-app-boot` 的 `readPluginMeta`):宿主先解析 `<包>/locale/en.json`,**然后 `scandir` 它所在目录** —— 所以 **`locale/` 目录不能整个删掉**,目录不存在会让 Plugins 页面直接显示一条红色诊断:`Plugin metadata for <包>: Error: ENOENT: no such file or directory, scandir '<绝对路径>/locale'`(错误发生在读 icon 之前,连图标都救不了它)。`locale/en.json` 是发现基线(缺它整个目录都不读),`exports` 必须暴露 `./locale/*.json` 与 `./package.json`。**失败是静默降级 + 一条诊断**,不会拦住插件加载。
230
+
231
+ ---
232
+
233
+ ## 7. 面板读数口径
234
+
235
+ 完整表见 [README.md](README.md) 的「面板怎么读」;开发时最容易搞错的几条(面板 hover 里也有同一份):
236
+
237
+ - **场景标签**(标题栏 `流式` / `空闲 Ns`)是解读所有数字的前提;空闲基线要 60 秒净增为 0 才算真的空闲。
238
+ - **命中 a/b** = 四条腿任一给出名字的长任务数 / 长任务总数。**它会被 DOM 腿抬高**(流式时写入窗口内几乎必然有写入),必须配合「归属 Top」分布读。
239
+ - **帧内未归因**(2026-10-07 起在「取证明细」组里,不在常显):`idle = 帧间隔 − 插桩回调耗时`,所以**没插桩时它退化成平均帧间隔**(实测 16.7ms 被当成未归因)—— 那时面板直接写「要深录」,连"该帧几乎无 JS 解释"的结论也不报(`unexplainedFrame` 先要求 `state.deep === true`;不要求的话这条结论在**每一条**长帧上都成立,等于恒报)。插桩后:峰值 ≈ 最差帧(≥95%)时给结论,并提示用 Rendering 面板的 Paint flashing 交叉验证。**CSS 动画只在这里现形**(它不经 JS 调度器)。
240
+ - **帧严重度只有一个口径:`frameSeverity(row) = max(blocking, 帧时长)`** —— 判据、排序、标签**同一个函数**(以前 `worstLoaf` / `loafRanks` 只看 `blocking`、`pickList` 看 `max`,于是"自动选中的那条"可能不在清单里,标签显示 `帧 0/N`)。⚠️ **不能只看 `blocking`**:实测一次 45 帧的会话里 `blocking` **全部 < 100ms**(最大值 92ms),只看它会让清单退回"全部",而"帧时长"才是面板自己的严重度口径(判色 120/200ms、圆环上 `帧里花在哪` 也是它)。
241
+ - **翻帧清单只收达线的帧(`pickList`)**:判据是 `frameSeverity(row) ≥ PICK_MIN_MS`(取 `WATCH_TRIGGER_MS` = 100ms)。**标签显示的是帧时长**(与圆环同一个数,`blocking` 放在 tooltip 里),否则会出现"清单里出现 92ms"这种自相矛盾的读数。**一条都不到线时退回全部**(`filtered:false`,标签上不显示 `≥100ms`),否则按钮点了像坏的。
242
+ - **看的是哪一帧(`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
+ - **事故选中的是「事故 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` 与面板那个帧环不是同一份数据**,这一层只做就近。
245
+ - **严重度必须「文字 + 颜色」两样给**:`severityLevel(ms)` → `bad`(≥200ms,与判色红档同一条线)/ `warn`(≥100ms,守夜触发线)/ `ok` / `none`;配 `severityText` + `severityColor` 一起用。只给颜色在灰度截图 / 色盲下等于丢信息。
246
+ - **宿主 event-loop 那一行给 p50 / p99 / max 三个数**(宿主半每秒采三个数下发;README 早就写了 max,代码这次才跟上)。老版本宿主半没有 `max` 字段时**如实只给 p50/p99**,不补 0 —— 只给 p99 会把偶发的秒级停顿藏掉。
247
+ - **可疑脚本(证据 → 代码,「取证明细」里的最后一块)**:把「**事故 → 帧 → 来源 → 包 → 文件:行**」落到**个体脚本**上 —— 上面几块回答"时间花在哪个包、哪类调用"(排行),这一块回答"打开哪个文件、哪个函数"(定位)。最多 `EVIDENCE_MAX = 4` 行(按耗时降序),范围写在标题上:**事故优先**(`可疑脚本 · 事故 i84 · 窗口内`,事故的脚本是窗口切片、最多 5 条、**跨会话读回仍在**),没有事故脚本时才退回 `可疑脚本 · 本帧`;每行 `<函数名> @ <包>` + 毫秒,悬停给完整证据行,**点击复制**那条证据行;底下一行给**分母**(`4/11条脚本 · 定到包 1/11`)+ 口径 + 缺什么;一条名字都没拿到时收敛成一行 `可疑脚本:0 条定到名(窗口内 11条脚本)· 要深录`(不摆一列"函数名未记录 @ 包未定名")。
248
+ **口径必须按三档读**(这也是这块存在的意义):
249
+ ① **直接测量** = 函数名(`sourceFunctionName`)与毫秒(`duration`)是 LoAF 给的;
250
+ ② **归因推断** = 包名 / 文件路径 / 行号由 URL 与 sourcemap 分段表推出来(`viaText` 压成五档:直接读出来 / 分段推断 / 不是插件脚本 / 解析失败 / 还没到位;原始 via 码进悬停与导出件);
251
+ ③ **尚无法确认** = 原始源码(TS/TSX)行号、调用栈、函数级耗时 —— 都拿不到。⚠️ **行号是"脚本文本自己的行号"**(单包 URL = 那个文件;合并批 = 合并后的大文件),**不是原始源码行** —— 要那个得解 section 内层 `mappings` 的 VLQ,本项目没做。所以这一层能缩小到"哪个包的哪个文件哪一段",**不能直接指出根因**;它也不承诺"跳到源码某一行"。
252
+ **函数名经常缺失**:合并/压缩后的脚本里 LoAF 常常不给 `sourceFunctionName`,面板照实写「函数名未记录」。**默认档(不插桩)看不到文件与行号** —— 合并批的包名本身就要精确归因,那一块会如实写"要深录"。
253
+ ⚠️ **组合 URL 的坑(实测踩过)**:`new URL('http://h/plugins/??pkg/client.js').pathname` **只剩 `/plugins/`** —— `??` 的第一个 `?` 被当成查询串开始。所以组合 URL 的文件路径要自己拼(单包组合 ⇒ `plugins/<包>/<文件>`);有断言把这个坑钉住。另一半:单包组合(`??<一个包>/client.js`)以前**不预取脚本文本**(旧判据是 `indexOf(',') !== -1`,只看多包),于是它的 `charPos` 永远换不成行号 —— 现在只要 URL 是 `plugins/??` 就预取。
254
+ **事故那一侧优先用"存下来的定位"**(`incidentScriptEvidence` + `incidentEvidenceAt`):包 / 文件 / 行号在**捕获那一刻**就写进了 `summary.topScripts[]`,面板与「复制摘要」都读这份,只有老包缺字段时才回退现算。为什么必须这样:现算依赖**当前会话**的 `state.precise` 与内存里的脚本文本 / 分段缓存 —— **刷新之后两者都空**,现算会把存好的 pkg/file/line 变成 `null` / `还没预取`,历史事故的定位就丢了(实测:深录档抓一次 → 关掉深录再刷新 → 证据行与刷新前**逐字一致**)。两条路共用同一个函数,所以不会出现"面板上有、复制出来没有"。
255
+ **文件与行号是两个坐标系,不许拼**:`lineScope` 分三档 —— `file`(单包 URL / 单包组合:行号就是那个文件里的行,照旧 `文件:行`)/ `bundle`(合并批:行号是**合并后大文件**里的行,而文件只回答"这段代码属于哪个包的那个文件" → **只给文件**,行号单独写成「合并脚本行 N」)/ `null`(没拿到)。老包没有 `lineScope` 时按 `pkgVia === 'section'` 现推。
256
+ 数据来源与导出:`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`。
258
+ - **帧里花在哪 / 脚本细拆 / 按来源 / 按包 / 交叉表(都在「取证明细」)**:环**只画四段**(脚本 / 任务里没归到的 / 渲染 / 尾部),它说的是"帧的时间去哪了"这**一个**维度;脚本内部的「来源」细分与「按包」各是一条 **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
+ - **环自身的几何**:只用 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
+ - **任务那一段再按 `scripts[].duration` 分成"被 LoAF 归到脚本的"与"没归到的"(后者是**差值**:短于上报阈值的脚本 + 事件派发 / 微任务 / GC),脚本那一段再按 `forcedStyleAndLayoutDuration` / `pauseDuration` 分成 纯执行 / 强制样式布局 / 暂停(**三者是 `duration` 的分区,相加 = 脚本耗时**;为 0 的两项不显示,免得把行挤成两行)。**绘制与合成分不开**(页面内没有 API,那一层在缺失声明里),所以最后一段是"剩下的都在这儿",不能读成"样式布局占比"。
261
+ - **脚本侧是同一份耗时的三种切法,六行 = 三种 × 两个分母**:① 三分(纯执行 / 强制 / 暂停);② **按包**(`packageOfScript(url, charPos)`,与 `incident.packages` 同一条 resolve 路;**只覆盖定得到包名的脚本,合计 ≤ 脚本耗时** —— 定不到插件包名的那部分**另起一段「其余来源」,而且按原因给名字**(`unresolvedLabel`),不笼统写"未定名":URL 空 → `无 URL(内联脚本)`;URL 在 `plugins/` 下但没拆 → `插件批次(未按 sourcemap 拆)`;其余有 URL 的 → **直接用文件名**(多半是应用自己的外壳 bundle,实测 `/assets/index-<hash>.js`,React 根容器上的事件处理器就来自它)。⚠️ 这一层只覆盖插件包,所以「其余来源」那段**必须写毫秒**(只写条数会和同一行的 ms 混单位,实测被读成"按包这一层只有 16ms",而那条没定名的占了 234ms);同理,只报"有名字的那部分"会让人把那 200ms 当成这一层的全部 —— 实测窗口里 1116.8ms 脚本只有 200ms 定到包。**先看「其余来源」多少毫秒,再读排行**;这一行在 `top` 与 `byOther` 都空时才退成"未定名/要深录");③ **按来源**(`invokerLabel(script)`:LoAF 的 `invoker`,归一规则 `SCRIPT[…]`→`script`、带冒号取最后一段、截 28 字符、缺失退 `invokerType`;**覆盖全部脚本,合计 = 脚本耗时**)。②③ 的合计**本来就不相等**,不要互相印证 —— 有断言钉住这件事(160 vs 150)。「本帧」= 饼图那一帧(`framePkgTally`),「窗口」= 最近 `WATCH_LOAF_MAX` 条的聚合(`scriptPkgTally`,按窗口指纹 `loaf.length|最新一条的 t|resolveSeq` 缓存;本帧那份 ≤ `WATCH_SCRIPT_MAX` 条脚本,不缓存)。**窗口那三行必须带上它们的分母**(`windowScopeText`:条数 + 跨度 + **平均每帧脚本**)—— 它们是累计,`纯执行 21623.8ms` 不写分母就会被读成"某一次执行花了 21 秒"(真被这么质疑过:200 条长帧各百来毫秒,攒起来就是二十几秒);跨度越久累计越大,所以那一行还写明"别读成越来越慢"。⚠️ **指纹里的 `resolveSeq` 是修掉的真 bug**:以前指纹只有 `条数|最后一条 t`,与**异步**到位的 sourcemap 分段表 / 脚本文本无关 → 环满 200 条而一时没有新 LoAF 时,「窗口」三行与交叉表**永久冻在**"插件批次(未按 sourcemap 拆)",而同屏「本帧」行(不缓存)已经给出真名,打开深录也不刷新。现在 `markResolved()` 推进 `resolveSeq` —— 每个 `entry.state = 'ready'/'failed'` 之后、以及 `setPrecise()` 里各推一次;**加新的"解析完成"路径时也必须推一次,否则又会冻住**。`tallyScripts` 因此多带 `byOther`(其余来源:文件名 / 无 URL / 插件批次,带毫秒)、`unresolvedMs`、`count` 与 `spanMs`(最后一条 t − 第一条 t),`spanText(ms)` 把它写成人读的`43分12秒`。
262
+ - **拆解视图的铁律(2026-10-08 定,来自成熟工具的做法)**:面板上凡是"把一份总量拆成比例"的地方,都按这三条来 ——
263
+ ① **一张图只表达一个维度**。同一总量的**分区**(互斥、相加=100%,如帧的任务/渲染/尾)是一条 100% 堆叠条,段少(≤4)时才用环;同一总量的**另一种切法**(正交、各自相加=100%,如按包 / 按来源)**不能并列进同一张图** —— 那种错法是"一半弧按包、另一半按来源",我们**没有**这么干。环的适用边界是"≤4 段、**不带嵌套**":层级在环上只能靠同色深浅 + 缝表示,读者得 hover 才读懂,84px 里 2% 的段直接看不见。**这条债务 2026-10-08 已还**:环只画四段,来源与包各一条 100% 堆叠条,两个正交维度做成交叉表;改动的实测证据在 [REVIEW.md](REVIEW.md) §2 的"拆解视图"一行。
264
+ ② **层级**才用火焰/冰柱图(帧 → 任务 → 脚本 → 函数);**帧内脚本时间轴做不了** —— LoAF 只给每条脚本的时长,不给它在帧内的起止。
265
+ ③ **跨视图一致 + 自带分母 + 绝对值与占比并列**:同一个来源在环、条、表里必须同色;每条轴都要写出自己的分母(窗口那行的 `窗口:200 条长动画帧 · 跨 43分12秒 · 平均每帧脚本 108.1ms` 就是范式);只给百分比不给毫秒会被读错。Sankey / 平行集**不做**:表达力换来的是可读性,面板这块地方不划算。
266
+ - **这一层的天花板(实测,不是推断)**:`invoker` 只回答"哪一类调用",函数级要采样器;页面内唯一那个是 JS Self-Profiling —— `typeof Profiler === 'function'`,但 `new Profiler({…})` 抛 `NotAllowedError: JS profiling is disabled by Document Policy.`,因为 HTML 响应**没有** `Document-Policy` 头(实测 `null`),而那个头由 DSH 服务的 HTML 决定(动它就越过 §0 的边界)。→ 函数级只剩 CDP / DevTools Performance(`missing` 里的 `devtools-trace`);宿主进程的函数级另有 A5 的 `node:inspector`。
267
+ - **脚本那几行走块式布局,并分成两个小标题**:「本帧」那组(脚本细拆 / 按来源条 / 按包条)与「窗口」那组(标题自带分母)各由一行小标题管范围 —— 行标签就不再重复"本帧 / ·窗口"(省的不是一两行,是每行三四个字)。值实测可到 352px(en 最坏),真数据下会超一行(`按来源` 39px = 两行:`DOMWebSocket.onmessage 425ms · setTimeout 421ms · …`),`S.row` 的右对齐会折成参差的两行(与 §5 那条"长文本用块式"是同一件事)。
268
+ - **面板会自己藏掉没意义的行**(字太多本身就让人读不动):**`回调归因` 那一块总在** —— 未插桩时降级成一行 `回调归因(耗时·下界)` + 值 `要深录`(**绝不写 `0ms/s`**,那会被读成"没有开销";插桩之后 `0ms/s` 才是真的,确实没有回调跑过),未插桩时 `top` / `其余 N` 行不渲染(免得是空表);以前整段不渲染,首屏就根本没有"回调归因"这一层,读者分不清"没采"还是"采了是空的"。标签就叫 `回调归因(耗时·下界)` / 英文 `attribution (time · lower bound)`,呼应"它是调度点包装能观察到的回调下界"。没插桩时不渲染 `DOM 写入(按插件) 0`;`命中` 那一行只列**非零的腿**(`hitText`;否则永远是 `245/0/0/0`),比率与概览同一口径(`ltBlamed/ltTotal`);`归属 Top` 的尾巴上不再重复「记录器自身 N×」—— 它上面就有独立一行。⚠️ **"一个包名都没定到"时看到的是什么**(这条记过一次教训):曾经有一段 `pkgZeroText` 想收敛成一行 `按包:0 条定到名(本帧 1 · 窗口 461 条脚本)· 要深录`,但它的分支**永远渲染不到**(外层要求 `window.js > 0`,而 `js > 0` 必然有脚本,脚本要么进 `top`、要么进 `byOther` 且 ms>0 —— 两个条件互斥),而套件当时**直接测那个函数**,于是一直绿。现在死分支与函数都**删了**,断言改成**实际渲染的那份数据**:喂给「按包」堆叠条的就是 `whoRows(tally)`,一个包名都没定到时看到的是**按原因命名的「其余来源」段(带名字与毫秒)**——`插件批次(未按 sourcemap 拆)` / `无 URL(内联脚本)` / 外壳文件名。行距也收紧过(`S.row` marginTop 5→4、`S.sep` 10/9→8/8)。
269
+ - **帧拆分的 clamp(向下夹)**:`frameSplit` 里 `js ≤ head`、`forced + pause ≤ js`、`exec ≥ 0` —— 每条脚本的毫秒各自 `round1`,脚本多而每条约 0.05ms 时求和会反过来超过父区间(四舍五入的算术后果);不夹就会画出负长度的弧。三条都有断言。
270
+ - **60 秒帧条(兼帧导航)**:一格一秒、格值 = 该格里**最差那一帧**的间隔;每格还多记一个 `t`(那一秒最差帧的 perf 毫秒),`sparkPickMap(now)` 给出"格 → 那一秒 `dur` 最大的 LoAF",`sparkCellTitle(cell, pick)` 给每格 `title`(墙钟时间 + 该秒最差帧 + **点得动 / 点不动**)。**只有那一秒真的有 LoAF 的格子才给 `onClick`** —— 帧条是按 `frameRing` 画的(每个 rAF 一条),翻帧却只在 `state.watch.loaf` 里翻,点一个没有 LoAF 的格子会"什么也没发生",那就是假按钮;点得动的格子把翻帧钉到那一秒的 LoAF。它铺满一行(`width: 100%`,实测 365px、一格约 6px;以前是 100px 宽的右对齐窄条,一格 1.7px 根本点不中),并挪到「取证明细」**最前面**。`sparkLevel` 的阈值与 `tools/inspect-incident.mjs` 的 ASCII 条带**逐条对应**(logic.mjs 有一条静态断言读那个文件核阈值,改一处忘另一处会红);整格 rAF 挂起画成矮灰柱。重绘指纹里带 5 秒刻度,否则空闲时帧条会冻在展开那一刻。
271
+ - **记录器自身(A1)**:每帧给 HUD 自己的 rAF 回调自计时,留最近 `SELF_RING = 600` 帧,面板与导出件(`context.recorder`)给 `p99` / 峰 / 样本数。这条的读法是"测量工具自证没有明显改变被测对象",**不要读成页面开销**。
272
+ - **深录(D1)是唯一的插桩开关**:`applyProbeState()` 只在 `state.deep` 打开时装 `setTimeout` / `setInterval` / `rAF` 与 `appendChild` / `insertBefore` / `setAttribute` 的包装,并同步精确归因(`setDeep` 里两者互锁)。守夜与现场是**同一档采集**,只差面板收放 —— 所以"展开面板会不会改变读数"的答案是**不会**。
273
+ - **归因表行**:`cps/s · 峰 cpsPeak/s`(EMA α=0.4 平滑的执行速率 / 单秒最大执行次数);行悬停给该秒同步耗时 `ms/s` 与累计 `N× / Xms`。覆盖范围只有三次调度的回调 → 是**下界**。
274
+ - **「其余 (N)」** = 除 Top3 外还有几个归属方(名字各不相同,按累计耗时排在后面),行值给它们的**合计速率**(与上面几行同一单位)。
275
+ - **DOM 写入(按插件)**:精确总次数 + 每个写入者的 `≈` 估算(1/10 采样,命中按 10 折算);带括号的名字来自"组件兜底桶"。**本 HUD 自身的写入不记账**。它现在在**「DOM 与堆」组**里(改版从「取证明细」挪过来:写入者属于 DOM,不属于"这一帧的时间去哪了"),且只有插桩时才有意义。
276
+ - **合并判色**:`fps ≤50` 或 `最差帧 ≥120ms` → 黄;`≤30` 或 `≥200ms` → 红(**任一触发即降色**;原来只看 fps,54.2fps 的绿点掩盖了同期 130ms 的帧)。
277
+ - **判色阈值同时写进导出 JSON 的 `context.thresholds`**;导出件还带场景 / 空闲秒数 / 精确归因与每个 map 的状态 / 宿主路由与 profile、Node、插件版本 / 实验候选与方法 —— 这是"文件离开现场后仍能自证口径"的设计,改读数口径时**必须同步改它**。
278
+ - **隔离实验**:所有窗口注入同一份合成负载(每 250ms 燃烧 8ms,走未插桩的 `natives.setInterval`,只烧 CPU 不动 DOM 以免污染归因表);每个候选只跟**紧邻基线**对照(A/B/A),**噪声底** = 基线↔基线漂移的最大绝对值,Δ 小于它一律标「噪声内」;窗口含流式标「不可比」且**不给结论**。它测的是**固定开销**,不是"该插件在某种用法下才贵"。
279
+
280
+ ---
281
+
282
+ ## 8. 已知坑与已证伪的死路
283
+
284
+ 仓库 / 发布侧:
285
+
286
+ 1. **改 `engines.dsh` / `peerDependencies` 前必须用真实 semver 验一遍**:宿主启动预检(`plugin-compatibility`)对 `@deepseek-ai/dsh` 与所有 `@deepseek-ai/dsh-*` peer 跑 `semver.satisfies(宿主版本, range, { includePrerelease: true })`,不满足就把**整行静默禁用**(只在 stderr 留一行 `disabling profile plugin …`)。实测(宿主 `0.2.0-rc.2`):
287
+ - `>=0.2.0-rc.2 <0.3.0-0` → PASS;`^0.2.0-rc.1` → PASS;`>=0.1.5-rc.1` → PASS
288
+ - `^0.1.5-rc.1` → **FAIL**(caret 上界落在下一个 minor 上,范围里没有与宿主同 `[major,minor,patch]` 元组、自带预发布标签的比较子 → 预发布版本被静默排除)。生态里 `dsh-context` 用 `>=0.1.5-rc.1` 这种**开区间**能过,换成 caret 就踩坑。
289
+ - 验法:`node -e "console.log(require('<dsh>/node_modules/semver').satisfies('0.2.0-rc.2', RANGE, {includePrerelease:true}))"`。
290
+ - `peerDependenciesMeta.optional` **不豁免**这条检查;`engines.dsh` 只是声明(市场卡片按它或 lockstep peer 判),不参与预检。
291
+ 2. **`git push` 不会带走 `refs/original/`**,但本机改写历史后旧提交仍在 `.git` 里;要真清掉得显式 `git update-ref -d refs/original/refs/heads/main && git reflog expire --expire=now --all && git gc --prune=now`(不可逆)。
292
+
293
+ 已证伪的路(**不要重试**):
294
+
295
+ 3. **包 `Context.emit` 抓不到 `session/event`**:该事件走**带作用域过滤的派发**(见 `@deepseek-ai/dsh-scope`),不经过公开的 `ctx.emit`。证据:装了 120 秒、期间一个子代理跑完整回合,`stats` 始终为空。→ 改用 `ctx.on(…)`,口径改成"提交 → 到达延迟"。
296
+ 4. **`launchctl submit` 在本机被拒**(Bad request / 服务未登记)。→ 排队重启用 `nohup … &`,验证器用 `setsid` 起独立进程组(§2.2)。
297
+ 5. **靠节点数凑 50ms 长任务**:12000 个离屏节点 React 提交仅 ~15ms,20000 个 ~28ms;而且**布局可能被推迟到下一帧**,任务里只剩十几毫秒。→ 必须用 `flushSync` 强制同步提交 + 在任务里强制布局(读 `offsetHeight`),仍不稳就**显式燃烧**(面板标注"含人为燃烧",不冒充自然卡顿)。
298
+ 6. **`require('react-dom')` 意外可用**:实测能拿到,`flushSync` 能强制同步提交(自测结果里的"(同步)"就是它)——别以为模块表里没有它。
299
+ 7. **`h(undefined, …)` 这类"引用了没声明"靠 `node --check` 拦不住**(语法合法)。实测事故:一次"整段替换样式块"把紧跟在 `const S` 之后的 `const signed` 与 `function PerfHudStress` 一起吞掉 → 文件里 `h(PerfHudStress, …)` 还在、定义没了 → React 抛 `Element type is invalid` → 外壳停用条目(面板凭空消失,只能刷新)。这就是 §3 那两条静态检查存在的原因。
300
+ 8. **名字黑名单是补丁**(§6.3):靠枚举宿主命名追不上宿主改名与压缩,真正的答案是四条腿交叉。
301
+ 9. **通知类插件可能整个会话零回调**:`dsh-notify-me` 实测从未出现在归因表里 —— 这是"它没干活",不是"没认出"。要验它,得先让它真的做点什么。
302
+ 10. **LoAF 的 `blockingDuration` 会是 0,哪怕这一帧确实很重**:受控实测(CDP 触发 `stress(8000, 200)`)那一帧帧间隔 **325.6ms**、窗口里 43 帧 >100ms,但同窗口 **116 条 LoAF 的 blocking 全是 0**;而既有的 longtask 表如实给出了 `223ms · 组件 PerfHud · 子树腿/DOM 写入腿 PerfHudStress`。所以:① **不要把 `blocking = 0` 读成"没有阻塞"**,它只说明这一帧里没有*被归因到*的长任务;② 事故的严重度以**帧间隔**为准,LoAF 的 blocking 只当补充。这次的 200ms 燃烧是经 CDP 触发的,所以不能反推"所有同步 JS 都不计"(可能只是没有 TaskAttribution)。**真人交互下的实测(2026-10-07,Windows Chrome,13 秒窗口)blocking 是 143.5 / 5.5 / 2.1ms —— 不是 0**:那条"全 0"是 headless/CDP 环境的产物,结论(blocking=0 不能读成没卡)不变。
303
+ 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
+ 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
+ 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 / 渲染),必须真的打开页面看一眼。
307
+ 15. **改 `client.js` 时不要同时跑"受控实测"**:`dsh-client-hmr` 会热载新代码,正在跑的采集会**换到新实例**(seq 归零、状态重置),实验结果直接作废。实测踩过一次:边写落盘功能边跑受控捕获,拿到的 id 与时间戳全对不上。要采集就先把代码定下来。
308
+ 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
+ 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 会很大所以别常开"里的**体积**不是理由,常开的代价在采样本身。反过来:回包体积从来不是问题,别为此加压缩/分片。
310
+
311
+ ---
312
+
313
+ ## 9. 文档地图
314
+
315
+ > 只有这六份。**不再新增带日期的记录文档** —— 结论并进这里的对应小节,历史进 [CHANGELOG.md](CHANGELOG.md)。
316
+
317
+ | 文件 | 管什么 |
318
+ |---|---|
319
+ | [README.md](README.md) | 用户视角:装什么、怎么装、读数怎么看、有什么风险 |
320
+ | [README_EN.md](README_EN.md) | 英文版(摘要式,结论与中文版一致) |
321
+ | [AGENTS.md](AGENTS.md)(本文) | 开发规则 + 源码级机制 + 已知坑 + 方向取舍(§0.1)+ 竞争位置(§12) |
322
+ | [CONTRIBUTING.md](CONTRIBUTING.md) | 贡献流程:报 bug、提 PR、提交前自检 |
323
+ | [REVIEW.md](REVIEW.md) | 发布:对外门槛、验收判据与实测证据、还剩什么(只列没做的) |
324
+ | [CHANGELOG.md](CHANGELOG.md) | 版本变更 |
325
+
326
+ ---
327
+
328
+ ## 10. npm 发版
329
+
330
+ 1. bump `package.json.version`,跑完 §3 的门禁并全绿。
331
+ 2. 打 tag `vX.Y.Z`(**tag 必须与 `version` 一致**),在 GitHub 建 Release。
332
+ 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 **不算**验证。
334
+ 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
+
336
+ ---
337
+
338
+ ## 11. 当前状态
339
+
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`。
343
+
344
+ **0.4.0 收进的改动**:「帧里花在哪」从三段饼改成四段饼
345
+ (脚本 / 任务里没归到脚本的 / 渲染 / 样式+布局+绘制),脚本侧再拆三层并分成**本帧与窗口两个分母**;
346
+ 随后一轮 UI 改版:折叠组变五组(新增「包 × 来源 · 窗口」`cross`,默认收起;`group()` 的 children 改成 thunk,
347
+ 收起时不算)、60 秒帧条铺满一行并挪到「取证明细」最前、可作帧导航、事故与帧改成按 id / 就近定位共享一个选中上下文、
348
+ DOM 写入挪进「DOM 与堆」、`回调归因` 未插桩时降级成一行 `要深录`;
349
+ 再一轮「证据 → 代码」完善:新增「可疑脚本」块(函数名 / 包 / 文件 / 行号 + 直接测量 vs 归因推断 vs 无法确认三档口径 + 一键复制),
350
+ `summary.topScripts[]` 多带 `invoker`/`file`/`line`/`lineVia`;顺手修掉组合 URL 的 `pathname` 截断(文件路径显示成 `/plugins/`)与
351
+ 分段表 `names` 未跟 `offsets` 一起排序两个真 bug;首屏的布局按钮改成按需出现、新增「最近 / 最差」scope 切换;
352
+ 悬停联动**停用且把辅助代码删干净**(复现不出来,详见 §7)。
353
+ 又一轮诊断链完善:**历史事故改为用捕获时存下来的定位**(`incidentScriptEvidence` / `incidentEvidenceAt`,面板与复制摘要共用一条路 —— 以前现算,刷新后缓存空了就把存好的 pkg/file/line 丢掉);
354
+ 合并脚本的「文件」与「行号」拆开显示(`lineScope`,`合并脚本行 N`,不再拼成看着精确的 `file:line`);新增**事故回放**(只用事故包里的 frames/loaf,刷新生效、零新增采集,且不排帧内执行顺序);
355
+ 删掉 REVIEW §3.4 记的不可达分支 `pkgZeroText`(连同它的函数),断言改成实际渲染的数据。
356
+ `npm test` 现在 **458** 条(attribution 11 / logic 408 / host 39),CI 门禁同步成 `-eq 458`;真页面 `check-panel` 验过(见 REVIEW §2)。
357
+
358
+ **仍然存在的债(套件是绿的 ≠ 它们没问题)**:
359
+
360
+ - ~~`pkgZeroText` 的「0 条定到名(带分母)」分支实际不可达~~ → **已还**:死分支与函数都删了,断言改成实际渲染的数据(见 §7)。
361
+ - `tallyScripts` 算出的 `unresolvedMs` **全仓没有读者**(只有定义处与测试断言引用它)。
362
+ - `stressText` / `pkgWhoText` / `invokerWhoText` 三个函数**零调用点**,却被 `tests/logic.mjs` 覆盖着 —— 这正是"套件绿"与"代码没死"之间的差距:断言只证明函数还算得对,不证明面板还会调它。
363
+ - 悬停联动**停用且辅助代码已删干净**(`hot`/`setHot`/`hotProps`/`hotStyle` 都没了):2026-10-08 会让外壳停用整个条目,2026-10-11 在当前代码上**复现不出来**(163 个元素真实鼠标 + 153 次 JS 派发 `mouseover` 全程存活),既不能证明根因也不能证明已修好 —— 恢复前必须先复现出来(配方在 §7),不要把它当可用特性写进任何文档。
364
+ - **原始源码行号 / 调用栈 / 函数级耗时仍然拿不到**:要解 section 内层 `mappings`(VLQ)或录 Chrome trace。「可疑脚本」只能缩小到"哪个包的哪个文件哪一段"。
365
+ - **函数名经常缺失**:LoAF 的 `sourceFunctionName` 在合并/压缩脚本里常常是空 —— 面板照实写「函数名未记录」,这不是 bug。
366
+
367
+ **卡在哪**:没有了 —— 0.4.0 已经发出去。**下一次发版前**只剩两件对外动作(细节在 [REVIEW.md](REVIEW.md) §3.1):
368
+ ① 在 npm 包设置页配 Trusted Publisher(Provider `GitHub Actions`,owner `yanzhaohui1999` / repo `dsh-perf-hud` / Workflow filename `release.yml`)
369
+ —— ⚠️ 新建配置**必须在 2 天内完成一次成功发布**,否则作废且无法编辑,所以**别提前配**;
370
+ ② 收录 PR 还没提。
371
+ `release.yml` 对"该版本已在 npm 上"幂等(发布前查 registry,查到就跳过;查询失败照实报错),所以给手工首发那一版建 Release 只会走"跳过"分支,不会再发一次。
372
+ 三条新发现的尾巴(A5 还没接进客户端/导出件、D4 的 `stressText()` 死代码、`locale` 的打包边界)也记在 REVIEW §3.2。已完成的验收证据在 REVIEW §2。
373
+
374
+ ---
375
+
376
+ ## 12. 竞争与位置(2026-10-07 复核)
377
+
378
+ 数值是 2026-10-07 当场查的(npm 窗口 `2026-09-05` → `2026-10-04`),**会过期,要用就自己重跑**
379
+ (命令在本节末尾)。判断三家时只用了两样东西:他们自己的源码/文档原文,和我当场查到的数字。
380
+
381
+ | 包 / 仓库 | 形态 | 撞我们哪块 | 势能 |
382
+ |---|---|---|---|
383
+ | `@linxin666/dsh-perf` | 宿主 + 客户端 HUD | 帧 / FPS / 长任务 / 宿主 event-loop / 内存 / 按插件归因 | **15,194 次/月**(生态第一,v0.3.14);仓库是通用插件仓 `zhu1090093659/dsh-web`(★8,471) |
384
+ | `dsh-fast`(PerryLink) | 宿主只读 | 会话加载 / spill / 压缩诊断 | 3,226 次/月 |
385
+ | `dsh-perfscope`(Evhye38496) | 静态体检 | 插件健康评分 | 846 次/月 |
386
+ | `dsh-performance-guard`(qinshige) | 宿主 + 客户端 | 隔离实验 + 诊断报告 | 435 次/月 |
387
+ | `Nwflower/dsh-perf-lens` | 宿主 + 客户端 | 四条腿里的三条 + 宿主半 | **未发 npm**,★0,创建 2026-10-05 |
388
+
389
+ 三条最有用的结论:
390
+
391
+ 1. **`@linxin666/dsh-perf` 的"按插件"归因是坏的**:它靠 `element.closest('[data-dsh-plugin]')`,
392
+ 而这个属性在上游 `0.2.1-alpha.1` 全仓 **0 命中** —— 官方外壳挂的节点拿不到名字,基本全落进
393
+ `rest` 桶。它的强项在治理(写盘频控、列表 publish gate),不在测量。
394
+ 2. **perf-lens 明确放弃了两块,正好是我们的位置**:它的路线图「What not to build」写着不做
395
+ **客户端 FPS/长任务面板**(让给 dsh-perf)与 **bisection**(让给 performance-guard);它自己把
396
+ jank 视图收到了 LoAF 的"脚本 + 强制样式布局",没有组件级归因(它明确否掉了 DOM 根启发式,
397
+ 理由是"测的是 DOM churn、真正的卡是每次变更的 JS 扫描时间")。
398
+ 3. **它的市场数据是错的**:`docs/landscape-survey.md` 与 `roadmap-proposals.md` 都写
399
+ `@linxin666/dsh-perf` 是 59,960 /「60k installs a month」,实测同窗口是 **15,194** ——
400
+ 引用任何人的市场结论前自己查一遍。
401
+
402
+ **一条桌面端的硬事实**(来自 perf-lens 的 `docs/evidence.md` §17,实测 900 条长帧):
403
+ `dsh-app://` 上 `PerformanceObserver` 照常交付长帧,但 **`scripts` 恒为空** —— LoAF 的脚本级归因
404
+ 只在 http(s) 源有效;桌面端能走的是 V8 CallSite 与主进程侧 CDP / `contentTracing`,而打包版
405
+ 默认**不带** `--remote-debugging-port`。
406
+
407
+ 复核命令:
408
+
409
+ ```sh
410
+ curl -s -o /dev/null -w '%{http_code}\n' https://registry.npmjs.org/dsh-perf-hud # 404 = 名字还空着
411
+ curl -s 'https://api.npmjs.org/downloads/point/2026-09-05:2026-10-04/@linxin666%2Fdsh-perf'
412
+ curl -s https://api.github.com/repos/Nwflower/dsh-perf-lens
413
+ curl -s https://raw.githubusercontent.com/Nwflower/dsh-perf-lens/HEAD/docs/roadmap-proposals.md
414
+ ```
415
+
416
+ ---