# @hyzyn/dsh-loglens 缺陷编号字典 > **这份文件是代码注释的编号字典,不是审计报告。** `src/` / `client-src/` / `test/` / `scripts/` > 里的 `(loglens Dxx)` 注释,含义是「这段代码为什么长这样」,编号的出处就是本文。 > 拿到任意一个 `(loglens Dxx)`:先查 [§1 编号索引表](#1-编号索引表) 知道**当年坏了什么**, > 再查 [§2 编号字典](#2-编号字典这段代码为什么长这样) 知道**所以代码为什么写成这样**。 > > **编号是硬契约**:`D01–D11` 是 `packages/loglens` 内部序列,与 `docker` / `tty` 的 `Dxx` > **不共享**;跨包引用请写「loglens D01 / docker D03」。新缺陷接在 `D11` 之后, > **不得重号、不得回收空号**——源码里已有注释指向它们。 > > **本文不含**:逐条 postmortem(现场复现 / 回归 / 反向验证)。那是 commit message / PR > description 的内容;本文只留**结论与取舍**。 ## 1. 编号索引表 > 只回答「当年坏了什么」。**症状列的关键词就是检索锚点**——`git log -S'<关键词>'` 能直接落到修复提交。 | D | 症状(一句话:当年坏了什么) | 涉及文件 | |---|---|---| | D01 | 文件头的块注释里写了 glob(`packages//test/` 的上一版是带星号的写法),那个星号加斜杠提前闭合注释,整个测试文件加载失败、套件报 `ReferenceError: test is not defined` | test/loglens.test.ts | | D02 | Loki 的 `query_range` 里 **`end` 是开区间**(真机逐纳秒实测:`end = ts` 命中 0 行),而适配器按「含边界」把续页窗口右端收成游标那个 ts —— 那一批同 ts 的行整批落在窗口外,第二页回 0 行,**其后所有数据一起丢**(真机:80 行只取回 7 行) | src/backends/loki.ts | | D03 | 游标拿「同 ts 第几行」当坐标,但真 Loki 的**流次序与组内次序会随查询窗口变化**、`limit` 切在同纳秒组中间时交回的行也不稳定 —— 续页原地重复第一页(真机:41 页 280 行、只有 7 行唯一),要么丢行 | src/cursor.ts · src/backends/loki.ts | | D04 | 刚进卡片时「来源」下拉把**第一项**画成已选(`` 在没有对应选项时会被浏览器 **回落显示第一项**——那是一个源码里看不见的谎。所以 (1) 加一个 value 为空串的占位项,把「还没选」这句话 显式画出来;(2) 下拉的 value 与查询用的来源都取**回落之后的那一项**(`chosenTarget`), 于是「选中的来源被删掉」时两边一起回落,不会出现「下拉写着没选、请求却打一个已经不存在的 id」。 ### `D05` 行模型与输出 schema 是两份手抄的清单,漂了要等真机才炸 **症状**:`loglens_search` **带 `q`** 时报 `"value.lines[0].highlights" is not a declared property (additionalProperties: false)`,整个调用失败;同一来源**不带 `q`** 却一切正常。 **根因**:契约 `LogLine` 有 `highlights?: Array<[number, number]>`(面板靠它画高亮区间),ES 适配器在 后端真报 `highlight` 时才带上它;而 `LINE_SCHEMA`(工具的输出 schema)是**逐字段手抄**契约的、 且 `additionalProperties: false`,抄的时候没有它。只有带 `q` 的查询才会发出 `highlight` 子句, 所以这不是「有时候崩」,是「**带条件的查询必崩**」——而带条件查询恰好是最常用的那条路。 **为什么值得记**:两份清单手抄同一份契约,漂移是必然的,代价却落在最贵的地方(一次查询整体失败)。 同类缺陷在任何「输出 schema 逐字段抄行模型」的工具上都会重演——所以修法不只是补一个字段。 **修法**:`LINE_SCHEMA` 补 `highlights`(数组套数组;宿主校验器对 `items` 是递归下钻的,已用真实现 实测),并做**契约键集对账**把这类漂移挪回编辑器:键集(`LINE_CONTRACT_KEYS: Record`) 必须放在 **`src`**——`test/` 不被任何 tsconfig 收,放那儿等于把守卫挂在**没人执行的闸门**上(第一版就是 这么写的,实测给 `LogLine` 加一个字段后包 `typecheck` 仍然 exit 0、对账用例照样全绿)。分工是两半: 编译期管「契约加了字段」,`test/` 里的运行期对账管「schema 与键集一致」。 ### `D06` 锚点要么给得出坐标,要么就得容许「抄得不全」 **症状**:`loglens_context` 传 `ts` + 正文片段(真机给的是 `Application run failed`)时定位不到, 报「锚点行没在这次重查里返回」;调用方只好改用「窄时间窗 + filters + limit」自己捞一行。 **根因**:两条叠加,缺一条都不至于这么难走—— (a) 唯一的精确坐标 `id` **只在结构化返回值里、不在渲染文本里**,而 agent 看的就是渲染文本,抄不到; (b) 匹配是 `line.message === wantedMessage`,而正文在来源侧常带前缀后缀(真机那条是 `[ERROR] [2026-10-18 22:01:43] T:[] 1 o.s.boot.SpringApplication … Application run failed`), 抄片段必然不等。 **为什么值得记**:这类工具的常见修法是「改成模糊匹配」,但它天然会**静默挑错行**(同一个片段一小时 内能出现几十次),比报错更坏。所以放宽匹配的同时必须把**优先级与去歧义规则**写死,否则修掉的是 「定位不到」,换来的是「定位到别处」。 **修法**:(a) 渲染文本在正文**之后**追加 `#`(正文一个字不动;id 为空或非字符串时不渲染);(b) 匹配 分六档——id 精确(**原值优先**)→ 剥掉一个前导 `#` 的变体 → 正文严格相等 → 规范化空白后相等 → 规范化 空白后单向包含 → **`ts` 兜底**(这一档 **不看**有没有给正文:第一版把它挂在「没给正文」那一支上,于是行明明在返回值里却报「没返回」——一句 假话);为什么 id 要两个候选:「渲染 token `#doc-42` 回抄」与「真 id 以 `#` 开头」都要能用,而**任何 体贴性的改写都必须排在原值之后**(第一版先剥,于是真以 `#` 开头的 id 反而不可寻址——修一个 bug 又 造一个);高档位一律压过低档位;同档取 `|ts - anchorTs|` 最小的,**并列时按 id、再按正文的字典序**定 ——`ts` 相等时「更早」不是个可判定的说法,第一版那么写,实现里那个比较在 ts 相等时恒为假,等于 「数组里靠后的胜」,把结果又绑回了来源返回顺序(与 `D03` 同一条教训)。不做反向包含(短行会吃掉整段 长正文)。(c) 失配文案先列**这次实际试过哪几档**(没传的档不列),非 id 档命中时经 `warnings` 说明 用的是哪一档——模糊命中可能不是你要的那一行,**静默比报错更坏**;「给了东西但被规范化丢掉」(空白 id / 空白正文)与「压根没给」必须分开说,否则那句话就是假的。 ### `D07` 0 桶有两种成因,而当时连判据都没请求 **症状**:按 `log.level` 做 facets 得到 0 桶 0 条,工具不给任何解释;真机上是靠行首的 `?` 反推 「级别是从正文推断的、`log.level` 整列为空」——白烧一轮试错。 **根因**:0 桶的两种成因(**字段是空壳** / **窗口里压根没日志**)指向**相反**的下一步,而区分它们的 唯一判据是「这个窗口里有多少条日志」;facets 的请求体写死 `track_total_hits: false`,这个数**压根 没要**——于是工具手里没有判据,只能把 0 桶原样回报。 **为什么值得记**:这是「诚实的截断信号」那条口径的**反向缺口**:不是把已知的事说错了,而是**该知道 的事没去问**,于是判断被整体推给调用方去试错。同类的还有「这个字段是不是空壳」——`_field_caps` 答不了它,只有聚合能答。 **修法**:只在**传了 `by`** 时才开 `track_total_hits`(精确计数有代价,而这个判据只服务于「字段是否 空壳」;只按 `interval` 分桶时 `over_time` 自己就说明了有没有日志)。窗口命中数经 `FacetsResult.totalHits` 只供执行层判读,**绝不进返回值**——facets 的输出 schema 同样是 `additionalProperties: false`,多一个键就是 `D05` 同款崩溃;措辞一律走 `warnings` 通道。措辞把 **判据本身**(多少条、精确还是下界)说出来,**有桶时一句都不说**。 ### `D08` 「带 PIT 的搜索不带索引」是对的,但那不是**所有**搜索 **症状**:同一张卡片里,「按字段分组」的榜与时间分布,跟下面的行来自**不同的数据集**——后者是来源 声明的索引范围,前者是全集群。用户看到的是「榜里有个服务、点进去却没有行」或者反过来,而界面上 没有任何一句话提示这两者口径不同。 **根因**:`targetAccess()` 把地址剥成 `origin`(凭据转成 Basic 头、路径留给索引表达式),于是调用方 **自己决定**要不要拼 `targetIndex(target)`: - `query`(PIT 翻页)走裸 `/_search`——**这是对的**:PIT 已经把索引集绑进了搜索上下文,路径上再带 索引反而错(`test/elastic.test.ts` 文件头正是这么写的,那条注释至今成立); - `_pit` / `_count` / `_field_caps` 都拼了 `/<索引表达式>/…`——也对; - **只有 facets 漏了**:它不开 PIT,却照抄了「带 PIT 那条路」的裸路径。 **为什么值得记**:这是一条**正确的规则被套用到了不适用的场景**。裸 `/_search` 在 PIT 语境下是对的, 于是它看起来像「本包的既有约定」;而 facets 是另一条语境(无 PIT),照抄就变成全集群通配。同一份 代码里两条语境并存时,「照抄邻居的写法」是最容易犯也最难在 code review 里看出来的错——因为那一行 本身没有可疑之处。 **修法**:facets 拼 `/<索引表达式>/_search`;同时给假服务器加一条**请求形态**断言 (`expect(search?.path).toBe('/' + INDEX + '/_search')`)——它钉的是「请求真的落在来源声明的范围内」, 不是「有没有发出去」。守卫已实测会红(改回裸路径 → `expected '/_search' to be '/logs-*/_search'`)。 **代价(明写)**:修完之后,在「地址里只写了 host、索引表达式是默认 `*,-.*`」的来源上,**榜与分布 的数字会变小**——原来混进来的 `.kibana_*` / 别的应用索引不再计入。这是修复,不是回归;但看惯了旧 数字的人会看到一次跳变。 ### `D09` 「一个窗口」取不出「锚点前后各 N 行」 **症状**:真机上 `loglens_context({ ts, id })` —— 用的就是 `loglens_search` 刚返回的那一行的 `ts` 与 `id`、 **逐字照抄**——报「锚点行没在这次重查里返回」。换成只给 `ts`、给整条正文、给正文片段,五种写法**全部**失败。 **根因**:重查是「一个窗口 `[ts-span, ts+span]` + `limit = before+after+1`(默认 11)+ `direction: backward`」。`backward` 取的是窗口里**最新**的 11 条,而 `before` / `after` 的语义是「锚点前后各 N 行」。 两者只在窗口足够**稀疏**时才碰巧一致。真机(455 条/分钟)实测:窗口 60s 内有 636 条,取回来的全是最新的 `04:35:00.0xx`,锚点 `04:34:53` 压根不在其中——于是连兜底的「`ts` 精确相等」也救不回来。 **为什么单测没抓到**:`contextBackend()` **无视 `query` 参数**、永远返回同一份 5 行假数据, 「窗口里只有 5 条」这个前提被白送了进去,`limit=11` 必然覆盖全窗。**假后端不实现真语义,反例就是 装饰**——与 `D02` 同一条教训,只是这次栽在「密度」而不是「边界」上。 **修法**:拆两段取,各自边界都成立—— - 早段 `[ts - span, ts]` + `backward` + `limit = before + 1`(从锚点往回数); - 晚段 `[ts, ts + span]` + `forward` + `limit = after + 1`(从锚点往后数)。 **两段都要取、合并去重**,因为「锚点落在哪一段」取决于后端的时间边界口径:ES 的 `range` 是**闭**区间 (两段都含锚点),Loki 的 `end` 是**开**区间(`D02`,只有晚一段含)。靠「谁家是闭区间」来省一次请求, 等于把适配器的实现细节写进工具层。 **连带必须改的一处**:合并后 `lines` 是**时间升序**,而旧切片按「新的在前」写成 `slice(index+1, …)` 取更早的行——只改取数不改切片,返回的「前 5 行」会是锚点**之后**的 5 行, **而且不报任何错**。这种「改了一半」比不改更难发现,所以两处都配了用例。 **代价(明写)**:一次 `loglens_context` 现在发**两次**查询而不是一次。这是用一次往返换「锚点一定在 结果里」——上下文查询本来就是调试动作、不是热路径。 **守卫**:新增一条**按窗口真过滤**的用例(锚点前后各铺 200 条、每 100ms 一条),它钉住的就是旧的 坏形状;实测把实现退回单窗口 → 该用例立刻红,报的正是真机上那句「锚点行没在这次重查里返回」。 ### `D10` 基线里带 `deferLoading` 的工具,模型**永远**等不到它 **症状**:`loglens_facets` 在模型侧根本不存在——不是「首屏省了、用到再加载」,是**一直不可用**。 公告(`LOGLENS_GUIDANCE`)还在教模型「`loglens_facets` 看分组与时间分布」,于是模型要么看不见这个工具, 要么按公告去调一个没声明的名字。偶发现象:打开一次设置保存(工具被重注册)之后它又能调了——因为那次 重注册让它在下一份 header 里成了「新增」,而新增是有激活路径的。 **根因**(两处源码,缺一不可): - `dsh-llm/lib/types/content.js` 的 `projectToolUpdates`:`offered = history.tools.filter(t => !t.deferLoading)`。 基线里的 deferred 工具**不进** offered 集合——注释原文是「Explicitly deferred baseline tools become available only after their first retained addition」; - `dsh-agent-loop`:`additions = tools.filter(t => !previousNames.has(t.name))`,而 `previousNames` 取自上一条 `request/header` 的工具名。**基线里已有的名字永远不会**再发一次 `tool-addition`。 两条合起来的判据一句话:`deferLoading` 只对「会话中途新增的工具」有意义;放在**首屏**的工具上, 它不是「延迟加载」,是「永不加载」。 **为什么单测没抓到**:T6 的试点用例断言的是「标记**只在** facets 上」,并把「标记到底生不生效」明确 推给「API 侧行为,要真 LLM 会话的轨迹才能观测」。那句话把判据推到了一个**不需要真机也能读出来**的 地方——宿主自己的投影代码就写着排除规则。**「需要真机」有时是「没人去读那两行」的委婉说法。** **修法**:撤掉那一行 `deferLoading: true`(`src/tools.ts`),定义回到首屏。 **代价(明写)**:facets 的定义 ≈1,195 B ≈340 token/请求 回到每次请求里。这是「工具真的存在」的价钱; 真要省必须走**注册面收窄**(按配置不注册这个工具),而不是请求宿主把它藏起来——藏起来的工具模型调不到, 省下的 token 买的是一个不存在的功能。 **守卫**:`test/tools.test.ts` 的断言从「标记只在 facets 上」翻成「**一个工具都不许带** `deferLoading`」, 并在注释里写明重新引入的前提(宿主提供 tool search,或我们自己在会话中途重注册出一条激活路径)。 ### `D11` settings 要在它可用的时候读,而不是在 `apply()` 顶部读 **症状**:桌面宿主(dsh 0.2.1-rc.5)上「设置 → 插件配置 → 日志查询」卡片照常渲染(版本号都对), 但右上角一行红字 `HTTP 404`,来源列表永远是「1 个来源」的空壳;agent 侧也一个 `loglens_*` 工具都没有。 **根因**:卡片是**浏览器半体**,它的存在只取决于包里的 `client.js`;而路由与工具由**宿主半体**注册。 宿主半体在 `apply()` 的第一行就读 settings: ```ts const scope = settingsEntryScope(ctx, 'loglens') // ctx 是插件自己的,inject: [] ``` 本插件 `inject: []`,而 cordis 4 对「没声明 inject 的服务」做属性访问是**直接抛** `cannot get property "settings" without inject`。宿主若**先挂插件、后让 settings 服务就绪**, 这一抛就把整条插件带下去(`required plugin did not activate`),四条路由与五个工具全没注册。 kit 的 `getService` 当时只按**返回值**判空、没有兜住抛错(见 kit D17),所以这一抛原样传到了宿主。 **为什么单测没抓到**:假 ctx 的 `settings` 是**注入式的**——`ctx.get('settings')` 直接返回对象, 属性访问也从不抛。真机的 cordis 用 Proxy 实现这道守卫,假 ctx 没有那一层,于是「服务先到还是后到」 这个维度在单测里根本不存在。 **修法**:与 docker / tty 同一个姿势——把设置读写口搬进 `ctx.inject(['settings'], settingsCtx => …)`, 用**绑定了 settings 的 ctx** 去 `settingsEntryScope()`。服务没到就永远不跑这个回调(而不是让 `apply()` 抛出去);`persist()` 在 scope 还没到位时照旧如实回报「只在本次进程内有效」。 **守卫**:`test/loglens.test.ts` 的「settings 服务后到」一组用例——用一个**属性访问会抛**的 Proxy 假 ctx (照真机形状)钉住三个出口:`apply()` 不抛且路由 / 工具已注册;服务到达前后同一条写盘路由的行为切换; 服务撤下(effect 释放)后回到如实回报。变异检查:把 `src` 与 `lib` 一起回退,这组用例全红。 ## 现状 **已修 11 / 待修 0**,编号至 `D11`。逐条症状见 §1,设计意图见 §2,**还没做的见 [ROADMAP.md](./ROADMAP.md)**。 ## 维护规则 1. **新缺陷只做两件事** → [conventions.md § 编号规范 硬规矩 7](../../docs/conventions.md#编号规范)。 2. **索引表的写入纪律** → [同上,硬规矩 4](../../docs/conventions.md#编号规范)。 3. **阈值 / 口径 / 基线数字只增不改** → [conventions.md § 文档分档 三条纪律第 3 条](../../docs/conventions.md#文档分档)。