From 4af5c6213c65a356c30481721b60bf169ba4c9a5 Mon Sep 17 00:00:00 2001 From: modusensus Date: Tue, 3 Nov 2026 12:23:14 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(panel):=20=E8=AE=B0=E5=BF=86=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E7=9A=84=E5=BD=92=E5=9B=A0=20state=20=E8=90=BD?= =?UTF-8?q?=E9=94=99=E4=BA=86=E7=BB=84=E4=BB=B6=EF=BC=8C=E6=89=93=E5=BC=80?= =?UTF-8?q?=E6=8A=BD=E5=B1=89=E5=BF=85=E5=B4=A9=EF=BC=88#553=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MemoryDrawer` 的归因 effect 与渲染块在读 `provenance` / `setProvenance`, 但那句 `useState` 却落在同文件上一个组件 `EntityPanel` 里。于是打开任何一条 记忆详情,渲染期直接 `ReferenceError: provenance is not defined`——崩的是 renderer,整个宿主要重启,进行中的会话被打断。0.10.0 起必现。 这个形状躲得过眼睛(两个组件隔三千多行、同名 state 看着很正常)、语法检查 (`provenance` 是合法标识符,只是不在作用域里)和原有用例(只读源码字符串, 从不执行组件)。所以除了把声明挪回 `MemoryDrawer`、删掉 `EntityPanel` 里那份 零引用的死代码,还补了一条按组件扫「调了 setter 却没声明」的用例——不锁具体 名字,锁这个结构。变异自证:把声明原样搬回 `EntityPanel`,用例当场打红 `MemoryDrawer calls setProvenance without declaring it`。 全量 2681 tests / 2680 pass / 0 fail / 1 skipped。 --- README.md | 6 +-- dsh-mneme/README.md | 6 +-- dsh-mneme/lib/client.js | 10 +++-- dsh-mneme/test/client.test.js | 73 +++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8bccb8ff..8cfd2526 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ license CI node - tests + tests coverage Awesome

@@ -272,7 +272,7 @@ dsh-mneme-serve # 默认 ~/.dsh/memory + 127.0.0.1:8790 ```bash cd dsh-mneme && npm install -npm test # 2680 个测试 +npm test # 2681 个测试 npm run stress # 三轴线压测 npm run sync # src → lib 同步 ``` @@ -554,7 +554,7 @@ Community projects that wire mneme into more platforms — not part of this repo ```bash cd dsh-mneme && npm install -npm test # 2680 tests +npm test # 2681 tests npm run stress # three-axis stress test npm run sync # src → lib sync ``` diff --git a/dsh-mneme/README.md b/dsh-mneme/README.md index 7a57c70b..c7160306 100644 --- a/dsh-mneme/README.md +++ b/dsh-mneme/README.md @@ -5,7 +5,7 @@ [![npm version](https://img.shields.io/npm/v/@modusensus/dsh-mneme?color=blue&label=npm)](https://www.npmjs.com/package/@modusensus/dsh-mneme) [![license](https://img.shields.io/badge/license-MIT-green)](LICENSE) [![Awesome](https://awesome-dsh-plugin.com/badge.svg)](https://github.com/awesome-dsh-plugin/awesome-dsh-plugin) -[![tests](https://img.shields.io/badge/tests-2680%20passed-success)](https://github.com/slow-stack/mneme) +[![tests](https://img.shields.io/badge/tests-2681%20passed-success)](https://github.com/slow-stack/mneme) [![CI](https://img.shields.io/github/actions/workflow/status/slow-stack/mneme/ci.yml)](https://github.com/slow-stack/mneme/actions) [![node](https://img.shields.io/badge/node-22%2B-blue)](https://nodejs.org) [![npm downloads](https://img.shields.io/npm/d18m/@modusensus/dsh-mneme.svg?color=blue&label=downloads)](https://www.npmjs.com/package/@modusensus/dsh-mneme) @@ -721,7 +721,7 @@ src/ ├── api.js # HTTP 路由(Web 面板数据通道,含 /conflicts 冲突队列) └── index.js # 插件接线 lib/ # src 的同步分发产物(npm run sync,prepack 钩子在发布时自动跑;一致性由 scripts/check-sync.js 在 CI 与 release.yml 校验——它只查不同步;唯一手写例外 lib/client.js——Web 面板 bundle,sync 不覆盖) -test/ # 2680 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) +test/ # 2681 个 node:test 测试(审计与三轴线压测不变量;src↔lib 一致性由 scripts/check-sync.js 发布闸门校验) scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压测 · sync-lib.js 同步 · check-sync.js 发布闸门 · benchmark-recall.js / benchmark-embed.js / benchmark-rerank.js 基准 · sync-test-badge.mjs 测试徽章 · build-runtime-manifest.mjs 运行时清单 ``` @@ -730,7 +730,7 @@ scripts/ # e2e-dsh.js 端到端演示 · stress-dsh.js 三轴线压 ```bash cd dsh-mneme npm install # 安装 peer 依赖(以 devDependencies 形式,用于本地测试) -npm test # 运行 2680 个测试 +npm test # 运行 2681 个测试 npm run stress # 三轴线压测:长会话检索 / 冲突仲裁 / 多 Agent 并发(离线 mock LLM) npm run sync # 把 src/ 同步到 lib/(发布时由 prepack 钩子自动执行) ``` diff --git a/dsh-mneme/lib/client.js b/dsh-mneme/lib/client.js index 4ae38763..397ec765 100644 --- a/dsh-mneme/lib/client.js +++ b/dsh-mneme/lib/client.js @@ -2831,9 +2831,6 @@ window.__ModuleLoader__.load({ // ④ 关系图谱 the same ego graph the old graph-only tab drew function EntityPanel({ t, focusEntity, onJumpMemory }) { const [entities, setEntities] = useState(null); - // #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。 - // null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。 - const [provenance, setProvenance] = useState(null); // null = still loading const [railQuery, setRailQuery] = useState(""); const [entityName, setEntityName] = useState(focusEntity || ""); const [depth, setDepth] = useState(1); @@ -6615,6 +6612,13 @@ window.__ModuleLoader__.load({ const [saveError, setSaveError] = useState(false); const [copied, setCopied] = useState(false); const [entities, setEntities] = useState(null); + // #512:这条记忆的归因——窗口级(哪一段原文蒸出来的)与行级(哪一轮铸造的)。 + // null = 还没读到(或读失败),两者要分得开,不能被当成「没有归因」。 + // 这个 state 必须住在**读它的那个组件**里:加归因区块时它曾被误放进 + // EntityPanel(同文件的上一个组件),于是 MemoryDrawer 的 effect 与渲染块 + // 引用到一个不存在的绑定,一开抽屉就 ReferenceError 把整个 renderer 带走 + // (issue #553)。同名 state 落在隔壁组件里,静态扫描与 review 都不容易看出来。 + const [provenance, setProvenance] = useState(null); // null = still loading // 整包退出的两段:先干跑清点(点按钮那一发),再确认物删。撤是**物理删除**不是归档 // (归档行仍占 share_external_id 的 UNIQUE 位,撤了会导不回来,理由在 // src/share-import.js 的 deleteSharePack),所以这里比删除那条路多一站清点。 diff --git a/dsh-mneme/test/client.test.js b/dsh-mneme/test/client.test.js index 5ce2fdb1..5ac807b7 100644 --- a/dsh-mneme/test/client.test.js +++ b/dsh-mneme/test/client.test.js @@ -2703,3 +2703,76 @@ test("entity graph surfaces the passive-confirmation review surface (issue #24)" assert.ok(clientSource.includes("if (boostedOpen) loadBoosted();"), "the review surface must not fetch while collapsed"); }); + +// issue #553:`MemoryDrawer` 用了 `provenance` / `setProvenance`,而那句 useState 却落在 +// 同文件的上一个组件 `EntityPanel` 里——于是打开任何一条记忆详情都会在渲染期抛 +// `ReferenceError: provenance is not defined`,整个 renderer 被带走(DSH 直接重启)。 +// +// 这个形状不是笔误级别的,它躲得过三样东西: +// · 眼睛——两个组件隔了三千多行,同名的 useState 看起来完全正常; +// · 语法检查——`provenance` 是合法标识符,只是不在作用域里; +// · 现有用例——它们读的是源码字符串,从不执行组件。 +// 所以这里不锁某一个名字,而是把**每个组件里「调了 setter 却没有声明」**这件事整体扫一遍, +// 才对得起「别再有人把 state 放进隔壁组件」这个意图。 +test("every client component declares the setters it calls (issue #553)", () => { + // 浏览器/宿主全局,不是 React state,不该被要求声明 + const GLOBALS = new Set([ + "setTimeout", "setInterval", "setImmediate", "setAnimationFrame", + "setState" // 子组件把 setter 当参数传下去时,形参名会命中,由 params 分支放行 + ]); + + const lines = clientSource.split("\n"); + // 组件 = 缩进 4 空格的 function 声明(本文件所有 UI 组件都是这个形状)。 + // 排除自身名字就叫 `setXxx` 的——那是普通的 setter 函数定义,不是组件 + // (`openLibrary` / `setOverlayOpen` 就是一对模块级函数,后者调用自身是递归)。 + const comps = []; + for (let i = 0; i < lines.length; i++) { + const m = lines[i].match(/^ function ([A-Za-z_$][\w$]*)\s*\(([^)]*)\)\s*\{/); + if (!m || /^set[A-Z]/.test(m[1])) continue; + let end = lines.length; + for (let j = i + 1; j < lines.length; j++) { + if (/^ function /.test(lines[j]) || /^\s{0,3}(const|let|export|class) /.test(lines[j])) { + end = j; + break; + } + } + comps.push({ name: m[1], params: m[2], start: i + 1, text: lines.slice(i, end).join("\n") }); + } + assert.ok(comps.length > 50, `expected the component scan to find many components, got ${comps.length}`); + + const offenders = []; + // 模块作用域里已经存在同名绑定时不算越界(`openLibrary` 调 `setOverlayOpen` 就是这类: + // 后者是模块级的普通函数,不是 React setter,而且函数声明会提升,先调后声明也成立)。 + // 整个 bundle 包在 `__ModuleLoader__.load({...})` 里,所以「模块顶层」= 缩进正好 4 空格。 + const moduleScope = new Set(); + for (const l of lines) { + if (!/^ \S/.test(l)) continue; + const f = l.match(/^ function\s+([A-Za-z_$][\w$]*)/); + if (f) moduleScope.add(f[1]); + const c = l.match(/^ (?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=/); + if (c) moduleScope.add(c[1]); + } + + for (const c of comps) { + const declared = new Set(); + // const [x, setX] = ... / const setX = ... + for (const m of c.text.matchAll(/const\s*\[[^\]]*?,\s*(set[A-Za-z_$][\w$]*)\s*\]\s*=/g)) declared.add(m[1]); + for (const m of c.text.matchAll(/const\s+(set[A-Za-z_$][\w$]*)\s*=/g)) declared.add(m[1]); + // 形参:组件自身的、以及体内嵌套函数/箭头函数的形参—— + // setter 经常这样往下传(`routeSelects(providerKey, modelKey, testState, setTestState, …)`)。 + for (const m of c.params.matchAll(/(set[A-Za-z_$][\w$]*)/g)) declared.add(m[1]); + for (const m of c.text.matchAll(/\(([^()]*)\)\s*(?:=>|\{)/g)) { + for (const p of m[1].matchAll(/set[A-Za-z_$][\w$]*/g)) declared.add(p[0]); + } + // 调用点:setXxx( ——排除 obj.setXxx( + for (const m of c.text.matchAll(/(? Date: Tue, 3 Nov 2026 12:45:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?docs(changelog):=20=E8=A1=A5=E4=B8=8A=20#55?= =?UTF-8?q?3=20=E7=83=AD=E4=BF=AE=E7=9A=84=20Unreleased=20=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index 6f1e6992..8e0c0039 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- **修一个打开记忆详情就崩掉整个宿主的崩溃(#553)**:0.10.0 里点开侧边栏任意一条记忆的详情抽屉,渲染进程立刻抛 `ReferenceError: provenance is not defined`。崩的是 renderer,整个宿主跟着重启,进行中的会话被打断,100% 复现。 + + 根因是两个隔了三千多行的组件:`MemoryDrawer` 的归因 effect 与渲染块在读 `provenance` / `setProvenance`,而那句 `useState` 落在了同文件**上一个**组件 `EntityPanel` 里(`74ba401` 加归因区块时放错了位置)。`EntityPanel` 里那份是纯死代码——整个函数体除声明外对 `provenance` 零引用。渲染先于 effect,所以第一帧就炸;就算渲染放过,effect 也会在 `setProvenance` 上炸。声明挪进 `MemoryDrawer`、死代码删掉,并写明这个 state 为什么必须住在读它的那个组件里。 + + 这个形状躲得过眼睛(隔三千行、同名 state 看着完全正常)、语法检查(`provenance` 是合法标识符,只是不在作用域里)和原有用例(只读源码字符串,从不执行组件),所以补的不是「锁一个名字」的用例,而是按组件扫「调了 `setXxx(` 却没有声明」——锁的是这个结构。扫描器要认的几种合法情形都放行了:`setTimeout`/`setInterval` 这类全局、把 setter 当形参往下传的嵌套函数、模块级同名 setter(`openLibrary` 调 `setOverlayOpen`,那是模块级 pub/sub,不是 React state)。变异自证:把声明原样搬回 `EntityPanel`,用例当场打红 `MemoryDrawer calls setProvenance without declaring it`。 + - **图谱的锚定层补上另外两路起点,被动确认有了能查的地方(#24 收尾)**:issue #24 的四块设计里,块 1 的「精确名 / Tag / 向量三路起点」当初只接了精确名一路——`anchoring.js` 本身早就支持多路(`anchorSeeds` 累加、`HOP_QUOTA` 分层都在),缺的是 `service.js` 的调用点只喂了 `normalizePath(..., { path: "exact" })` 一路,而通路身份在合并那一步就丢了(`normalizePath` 的返回值里没有 `path`,`anchorSeeds` 里 `p.path ?? "?"` 把每一路都记成 `"?"`)。块 4 的「例外管理」同样只有自动确认、没有可见面——面板里 `passiveConfirm` 一次都不出现。 **Tag 路走全等匹配,不走子串**:`store.findEntitiesByTags` 把查询按标点和空白切成词、滤掉单字(中文单字作 tag 没有区分度),再要求 `tags` 数组里**某个 tag 与词完全相等**。子串匹配会让 `ai` 命中 `mail`、让 `Java` 命中 `JavaScript`,而这条路的作用是「用户提到了某个主题词」,模糊命中的代价是把整类无关记忆拉进图轴。排序按命中记忆数降序、名称升序——同分时要有确定性顺序,否则同一查询两次跑出来的种子次序不同。