@modusensus/dsh-mneme 0.8.13 → 0.8.15

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.
Files changed (271) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +655 -626
  3. package/assets/icon.svg +3 -3
  4. package/bin/cli.mjs +689 -689
  5. package/bin/dsh-mneme-mcp.mjs +510 -510
  6. package/bin/dsh-mneme-serve.mjs +151 -0
  7. package/cordis.patch.yml +21 -21
  8. package/docs/CONFIGURATION.md +263 -0
  9. package/docs/CONTINUITY.md +124 -0
  10. package/docs/DAEMON.md +46 -0
  11. package/docs/ENTITIES.md +245 -0
  12. package/docs/LOCAL_MODEL.md +222 -0
  13. package/docs/SEMANTIC.md +256 -0
  14. package/docs/SLEEP.md +164 -0
  15. package/docs/STORAGE.md +89 -0
  16. package/lib/api-standalone.js +673 -572
  17. package/lib/api.js +1437 -1433
  18. package/lib/bootstrap.js +211 -211
  19. package/lib/client.js +5444 -5295
  20. package/lib/commands.js +95 -95
  21. package/lib/config.js +880 -836
  22. package/lib/content-hash.js +54 -54
  23. package/lib/continuity.js +220 -220
  24. package/lib/document-index.js +94 -94
  25. package/lib/document.js +303 -303
  26. package/lib/dream/clustering.js +118 -118
  27. package/lib/dream/decisions.js +674 -674
  28. package/lib/dream/narratives.js +44 -44
  29. package/lib/dream/sleep.js +819 -819
  30. package/lib/dream.js +1501 -1501
  31. package/lib/embedding.js +177 -177
  32. package/lib/entities/extractor.js +252 -252
  33. package/lib/graph/anchoring.js +179 -179
  34. package/lib/guide.js +74 -74
  35. package/lib/heat.js +144 -144
  36. package/lib/hot-memory.js +46 -46
  37. package/lib/index.js +535 -671
  38. package/lib/inject.js +464 -450
  39. package/lib/lang.js +248 -229
  40. package/lib/local-embedder.js +358 -358
  41. package/lib/maintenance.js +142 -142
  42. package/lib/mirror.js +330 -330
  43. package/lib/organize.js +450 -450
  44. package/lib/peak-hours.js +117 -117
  45. package/lib/placement.js +13 -0
  46. package/lib/pre-inject-gate.js +340 -0
  47. package/lib/quality-filter.js +126 -126
  48. package/lib/recall-stats.js +175 -175
  49. package/lib/reranker.js +269 -269
  50. package/lib/runtime/adopt.js +176 -176
  51. package/lib/runtime/closure.js +172 -172
  52. package/lib/runtime/download.js +247 -247
  53. package/lib/runtime/layout.js +265 -265
  54. package/lib/runtime/loader.js +252 -252
  55. package/lib/runtime/model-fetch.js +361 -361
  56. package/lib/runtime/provision.js +237 -237
  57. package/lib/runtime/tarball.js +130 -130
  58. package/lib/runtime/verify.js +223 -204
  59. package/lib/scope.js +146 -146
  60. package/lib/search/adaptive.js +60 -60
  61. package/lib/search/bm25.js +96 -96
  62. package/lib/semantic.js +229 -0
  63. package/lib/sensitive-scan.js +209 -209
  64. package/lib/serve.js +215 -0
  65. package/lib/service.js +2663 -2596
  66. package/lib/settings.js +517 -509
  67. package/lib/store.js +3007 -3007
  68. package/lib/summarize.js +891 -856
  69. package/lib/tools.js +743 -737
  70. package/lib/vector-index.js +110 -110
  71. package/lib/version-check.js +122 -122
  72. package/lib/write-admission.js +402 -402
  73. package/locale/en.json +6 -6
  74. package/locale/zh.json +6 -6
  75. package/package.json +8 -4
  76. package/runtime-manifest.json +526 -526
  77. package/scripts/benchmark-embed.js +201 -201
  78. package/scripts/benchmark-recall.js +249 -249
  79. package/scripts/benchmark-rerank.js +166 -166
  80. package/scripts/build-runtime-manifest.mjs +224 -224
  81. package/scripts/changelog-prep.mjs +36 -0
  82. package/scripts/check-sync.js +42 -42
  83. package/scripts/e2e-dsh.js +220 -220
  84. package/scripts/mneme-runtime.mjs +277 -277
  85. package/scripts/stress-dsh.js +255 -255
  86. package/scripts/sync-lib.js +47 -47
  87. package/scripts/sync-test-badge.mjs +69 -69
  88. package/scripts/test-count-sync.mjs +44 -44
  89. package/src/api-standalone.js +673 -572
  90. package/src/api.js +1437 -1433
  91. package/src/bootstrap.js +211 -211
  92. package/src/commands.js +95 -95
  93. package/src/config.js +880 -836
  94. package/src/content-hash.js +54 -54
  95. package/src/continuity.js +220 -220
  96. package/src/document-index.js +94 -94
  97. package/src/document.js +303 -303
  98. package/src/dream/clustering.js +118 -118
  99. package/src/dream/decisions.js +674 -674
  100. package/src/dream/narratives.js +44 -44
  101. package/src/dream/sleep.js +819 -819
  102. package/src/dream.js +1501 -1501
  103. package/src/embedding.js +177 -177
  104. package/src/entities/extractor.js +252 -252
  105. package/src/graph/anchoring.js +179 -179
  106. package/src/guide.js +74 -74
  107. package/src/heat.js +144 -144
  108. package/src/hot-memory.js +46 -46
  109. package/src/index.js +535 -671
  110. package/src/inject.js +464 -450
  111. package/src/lang.js +248 -229
  112. package/src/local-embedder.js +358 -358
  113. package/src/maintenance.js +142 -142
  114. package/src/mirror.js +330 -330
  115. package/src/organize.js +450 -450
  116. package/src/peak-hours.js +117 -117
  117. package/src/placement.js +13 -0
  118. package/src/pre-inject-gate.js +340 -0
  119. package/src/quality-filter.js +126 -126
  120. package/src/recall-stats.js +175 -175
  121. package/src/reranker.js +269 -269
  122. package/src/runtime/adopt.js +176 -176
  123. package/src/runtime/closure.js +172 -172
  124. package/src/runtime/download.js +247 -247
  125. package/src/runtime/layout.js +265 -265
  126. package/src/runtime/loader.js +252 -252
  127. package/src/runtime/model-fetch.js +361 -361
  128. package/src/runtime/provision.js +237 -237
  129. package/src/runtime/tarball.js +130 -130
  130. package/src/runtime/verify.js +223 -204
  131. package/src/scope.js +146 -146
  132. package/src/search/adaptive.js +60 -60
  133. package/src/search/bm25.js +96 -96
  134. package/src/semantic.js +229 -0
  135. package/src/sensitive-scan.js +209 -209
  136. package/src/serve.js +215 -0
  137. package/src/service.js +2663 -2596
  138. package/src/settings.js +517 -509
  139. package/src/store.js +3007 -3007
  140. package/src/summarize.js +891 -856
  141. package/src/tools.js +743 -737
  142. package/src/vector-index.js +110 -110
  143. package/src/version-check.js +122 -122
  144. package/src/write-admission.js +402 -402
  145. package/test/adaptive-inject-budget.test.js +85 -85
  146. package/test/api-routes-gaps.test.js +218 -218
  147. package/test/api.test.js +1702 -1688
  148. package/test/audit.test.js +448 -448
  149. package/test/baseline-persist.test.js +248 -248
  150. package/test/benchmark.test.js +94 -94
  151. package/test/bootstrap.test.js +220 -220
  152. package/test/changelog-prep.test.js +60 -0
  153. package/test/client.test.js +1230 -1169
  154. package/test/clustering.test.js +100 -100
  155. package/test/commands.test.js +106 -106
  156. package/test/config.test.js +66 -66
  157. package/test/conflict-freeze.test.js +300 -300
  158. package/test/content-hash.test.js +204 -204
  159. package/test/continuity.test.js +333 -333
  160. package/test/document-dir.test.js +233 -233
  161. package/test/document.test.js +671 -671
  162. package/test/dream-archive-guardrail.test.js +116 -116
  163. package/test/dream-candidate-hybrid.test.js +281 -281
  164. package/test/dream-coverage-degrade.test.js +86 -86
  165. package/test/dream-failure-backoff.test.js +173 -173
  166. package/test/dream-peak-hours.test.js +194 -194
  167. package/test/dream.test.js +1385 -1383
  168. package/test/embedding-ready.test.js +196 -196
  169. package/test/entities.test.js +627 -547
  170. package/test/entity-recall.test.js +179 -179
  171. package/test/epistemic.test.js +325 -325
  172. package/test/fnew-0112.test.js +311 -311
  173. package/test/fnew-03.test.js +422 -422
  174. package/test/graph-anchoring.test.js +184 -184
  175. package/test/graph-api.test.js +175 -175
  176. package/test/graph-inject-budget.test.js +103 -103
  177. package/test/graph-passive-confirm.test.js +91 -91
  178. package/test/graph-weight-evolution.test.js +104 -104
  179. package/test/heat.test.js +175 -175
  180. package/test/helpers/dream-mock.js +82 -82
  181. package/test/helpers/peer-worker.mjs +25 -25
  182. package/test/helpers/runtime-source.js +60 -60
  183. package/test/helpers/tar-builder.js +80 -80
  184. package/test/helpers/write-admission-samples.js +308 -308
  185. package/test/hot-memory.test.js +145 -145
  186. package/test/inject-content-cap.test.js +91 -91
  187. package/test/inject-first-round-bm25.test.js +89 -89
  188. package/test/inject-heat.test.js +77 -61
  189. package/test/inject-parent-gate.test.js +106 -106
  190. package/test/inject-pools.test.js +194 -194
  191. package/test/inject-recall.test.js +105 -105
  192. package/test/inject-rotation.test.js +161 -161
  193. package/test/inject.test.js +420 -420
  194. package/test/lang.test.js +87 -87
  195. package/test/lastrunat-persist.test.js +101 -101
  196. package/test/lib-smoke.test.js +96 -96
  197. package/test/llm-audit.test.js +598 -598
  198. package/test/llm-message-source.test.js +207 -207
  199. package/test/local-embedder.test.js +292 -292
  200. package/test/maintenance.test.js +391 -391
  201. package/test/mcp-stdio.test.js +385 -385
  202. package/test/mirror-dirty.test.js +424 -424
  203. package/test/mirror-edit-digest.test.js +187 -187
  204. package/test/mirror-generation.test.js +499 -499
  205. package/test/mirror-types.test.js +208 -208
  206. package/test/mirror.test.js +249 -249
  207. package/test/narrative-bars.test.js +162 -162
  208. package/test/organize.test.js +418 -418
  209. package/test/peer-blockers.test.js +193 -193
  210. package/test/placement.test.js +36 -0
  211. package/test/policy-epoch.test.js +259 -259
  212. package/test/pre-inject-gate.test.js +434 -0
  213. package/test/quality-filter.test.js +186 -186
  214. package/test/reasoning-effort.test.js +752 -752
  215. package/test/recall-anti-update.test.js +82 -82
  216. package/test/recall-evals.test.js +253 -253
  217. package/test/recall-layer.test.js +326 -326
  218. package/test/recall-stats.test.js +189 -189
  219. package/test/receipt-chain.test.js +451 -451
  220. package/test/reflection.test.js +226 -226
  221. package/test/reindex-backfill.test.js +62 -62
  222. package/test/reranker-single-logit.test.js +73 -73
  223. package/test/reranker.test.js +197 -197
  224. package/test/resident-status.test.js +83 -83
  225. package/test/runtime-adopt.test.js +206 -206
  226. package/test/runtime-cli.test.js +89 -89
  227. package/test/runtime-closure.test.js +193 -193
  228. package/test/runtime-download.test.js +302 -302
  229. package/test/runtime-layout.test.js +163 -163
  230. package/test/runtime-loader.test.js +255 -255
  231. package/test/runtime-manifest.test.js +159 -159
  232. package/test/runtime-model-fetch.test.js +348 -348
  233. package/test/runtime-provision.test.js +101 -101
  234. package/test/runtime-tarball.test.js +81 -81
  235. package/test/runtime-verify.test.js +319 -279
  236. package/test/scope-candidates.test.js +239 -239
  237. package/test/scope-explicit.test.js +452 -452
  238. package/test/scope-inject-soft.test.js +106 -106
  239. package/test/scope-retrieval.test.js +197 -197
  240. package/test/scope-storage.test.js +255 -255
  241. package/test/scope-strict.test.js +367 -343
  242. package/test/search-fusion.test.js +90 -90
  243. package/test/semantic.test.js +124 -124
  244. package/test/sensitive-scan.test.js +207 -207
  245. package/test/serve-bin.test.js +121 -0
  246. package/test/serve.test.js +196 -0
  247. package/test/service-search.test.js +174 -174
  248. package/test/service.test.js +405 -405
  249. package/test/settings.test.js +261 -261
  250. package/test/skip-invalid-restore.test.js +262 -262
  251. package/test/sleep-action-set.test.js +422 -422
  252. package/test/sleep-heat.test.js +126 -126
  253. package/test/sleep-scheduler-rearm.test.js +89 -89
  254. package/test/sleep.test.js +527 -527
  255. package/test/standalone-api.test.js +751 -544
  256. package/test/store-vector-cache.test.js +98 -98
  257. package/test/store.test.js +396 -396
  258. package/test/stress.test.js +209 -209
  259. package/test/summarize-occurred-at.test.js +127 -0
  260. package/test/summarize-reasoning-effort.test.js +189 -189
  261. package/test/summarize.test.js +1465 -1465
  262. package/test/supersede-state-tracking.test.js +394 -0
  263. package/test/test-count-sync.test.js +60 -60
  264. package/test/tools-dto-schema-matrix.test.js +325 -325
  265. package/test/tools-memory-get-schema.test.js +65 -65
  266. package/test/tools.test.js +428 -374
  267. package/test/updated-at-semantics.test.js +113 -113
  268. package/test/vector-index.test.js +221 -221
  269. package/test/version-check.test.js +152 -152
  270. package/test/webServer-optional.test.js +82 -82
  271. package/test/write-admission.test.js +668 -668
@@ -1,325 +1,325 @@
1
- // issue #195:全工具矩阵的「DTO 键集 ⊆ output schema」系统性断言——防 #184 类漂移。
2
- //
3
- // 背景:#184 是 memory_get 的内联 output schema 漏声明了 v0.8.1 新增的 scope 来源
4
- // 三键,而 schema 是 `additionalProperties: false`——toApiList 条件展开的键一旦没被
5
- // 声明,宿主 in-process 校验直接拒收("invalid output"),表现为「个别 id 必失败、
6
- // 多数正常」。单点回归(tools-memory-get-schema.test.js)只护住了 memory_get 一个
7
- // 工具:换一个工具、换一个键,同类事故可以原样重演。
8
- //
9
- // 这里把护栏提到矩阵层,四层断言各管一段:
10
- // A 实跑:9 个工具每个可安全触达的分支,拿**真实 execute 的返回值**过生产同款校验器
11
- // (validateJsonSchemaValue)——不写手抄的期望值,让运行时自己说话;
12
- // B 产地:DTO 的唯一产地 service.toApiList 在全形态数据下的输出 ⊆ MEMORY_ITEM_SCHEMA,
13
- // 并反向要求声明里的每个键都被至少一种形态真实产出(声明了却永不出现的键=死声明,
14
- // 会在下一次 schema 增键时立刻暴露);
15
- // C 结构:每个工具的 schema 必须闭合(additionalProperties:false)、required ⊆ properties、
16
- // 每项都带 type——否则 A/B 两层会因为校验器形同虚设而静默失效;
17
- // D 负例锁:故意注入一个未声明键,断言校验器必须报错。没有这一层,上面三层可以一起
18
- // 绿着却毫无保护力(校验器被换掉、schema 被放开都会静默通过)。
19
- import test from "node:test";
20
- import assert from "node:assert/strict";
21
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
- import { tmpdir } from "node:os";
23
- import { join } from "node:path";
24
- import { validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
25
- import { createStore } from "../src/store.js";
26
- import { createService } from "../src/service.js";
27
- import { createTools, MEMORY_ITEM_SCHEMA } from "../src/tools.js";
28
-
29
- // 矩阵清单:新增工具必须同时补进本文件的分支表,否则下面第一条断言会先失败提醒
30
- // ——这是「系统性」的含义:漏一个工具不会静默通过。
31
- const TOOL_NAMES = [
32
- "memory_save",
33
- "memory_search",
34
- "memory_list",
35
- "memory_get",
36
- "memory_update",
37
- "memory_delete",
38
- "memory_forget",
39
- "memory_archive",
40
- "memory_register_document",
41
- "memory_runtime"
42
- ];
43
-
44
- function setup(config = {}) {
45
- const store = createStore(":memory:");
46
- // config 同时喂 service 与 tools:#230 起 registerDocument 的闸在 service
47
- // 侧读同一个 config,只喂 tools 会让注册工具在矩阵里永远抛 disabled。
48
- const service = createService({ store, mirror: null, config });
49
- const registered = [];
50
- const ctx = {
51
- tools: {
52
- register(def) {
53
- registered.push(def);
54
- return () => {};
55
- }
56
- }
57
- };
58
- createTools(ctx, service, config, undefined);
59
- const byName = Object.fromEntries(registered.map((def) => [def.name, def]));
60
- return { store, service, byName };
61
- }
62
-
63
- // 驱动一个工具并立刻用它自己声明的 schema 校验真实返回值。
64
- // 返回 { value }:调用方需要时再对值做语义断言。
65
- async function drive(byName, name, args = [], exec) {
66
- const tool = byName[name];
67
- assert.ok(tool, `工具 ${name} 未注册`);
68
- const value = await tool.execute(args, exec);
69
- assertMatches(byName, name, value, `args=${JSON.stringify(args)}`);
70
- return value;
71
- }
72
-
73
- // 真实返回值必须被声明 schema 完全覆盖;失败时把违规明细一起抛出,定位更快。
74
- function assertMatches(byName, name, value, label) {
75
- const errors = validateJsonSchemaValue(byName[name].output.schema, value);
76
- assert.deepEqual(
77
- errors,
78
- [],
79
- `${name} 的真实返回未被 output schema 覆盖(${label}):${JSON.stringify(errors)}\n返回键:${JSON.stringify(Object.keys(value ?? {}))}`
80
- );
81
- }
82
-
83
- // 全形态种子:toApiList 的 7 个条件键只在对应列非 undefined 时展开,所以每种形态
84
- // 都要真实落一行,否则 B 层会(正确地)报「声明了却从未产出」。
85
- function seedShapes(store) {
86
- const rows = [];
87
- const push = (over) => {
88
- const memory = {
89
- type: "project",
90
- title: `shape-${rows.length}`,
91
- content: `形态 ${rows.length} 的正文`,
92
- source: "test",
93
- ...over
94
- };
95
- // #230:document 走存储层唯一铸造口(通用 save 拒绝 document)。
96
- rows.push(memory.type === "document" ? store.saveDocument(memory) : store.save(memory));
97
- };
98
- push({}); // 极简形态:只有基础键
99
- push({ sensitivity: "personal" });
100
- push({ occurred_at: "2026-09-15T00:00:00Z" });
101
- push({ agent_scope: "coder", workspace_scope: "proj-alpha" });
102
- push({
103
- // 全量标注:#184 的原始触发形态
104
- agent_scope: "coder",
105
- agent_scope_source: "auto",
106
- workspace_scope: "proj-alpha",
107
- workspace_scope_source: "explicit",
108
- scope_decided_at: "2026-09-15T00:00:00Z",
109
- sensitivity: "personal",
110
- occurred_at: "2026-09-14T12:00:00Z",
111
- tags: ["shape", "annotated"],
112
- importance: 5
113
- });
114
- // #230:document 指针行——doc_path 是 toApiList 的条件键,必须有一行真的产出它。
115
- push({ type: "document", title: "shape-doc", content: "指针行摘要", doc_path: "X:\\seed\\doc.md", evidence: [] });
116
- return rows;
117
- }
118
-
119
- test("工具矩阵与 TOOL_NAMES 清单一致(新增工具必须补进矩阵)", () => {
120
- const { byName } = setup();
121
- assert.deepEqual(Object.keys(byName).sort(), [...TOOL_NAMES].sort());
122
- });
123
-
124
- test("A 实跑:10 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
125
- const { store, service, byName } = setup({ documentMemoryEnabled: true });
126
- const shapes = seedShapes(store);
127
- const annotated = shapes[shapes.length - 1];
128
- const plain = shapes[0];
129
- const driven = new Set();
130
-
131
- const note = (name) => driven.add(name);
132
-
133
- // --- memory_save:created 与 merged 两条分支 ---
134
- await drive(byName, "memory_save", { type: "project", title: "save-new", content: "首次写入" });
135
- note("memory_save");
136
- await drive(byName, "memory_save", { type: "project", title: "save-new", content: "同键再写 → merged" });
137
- // 显式 scope + 敏感度 + 事件时间:把「条件展开的键」真的写进库(#184 的真实入口)
138
- const scopedSave = await drive(byName, "memory_save", {
139
- type: "preference",
140
- title: "save-scoped",
141
- content: "带显式 scope 的写入",
142
- agent_scope: "global",
143
- workspace_scope: "proj-beta",
144
- sensitivity: "personal",
145
- occurred_at: "2026-09-16T00:00:00Z"
146
- });
147
- assert.ok(scopedSave.id, "memory_save 必须回传 id");
148
-
149
- // --- memory_search:命中 / 空结果 / 显式 keyword 档 ---
150
- const hit = await drive(byName, "memory_search", { query: "形态" });
151
- assert.ok(hit.items.length > 0, "关键词检索应当命中种子行");
152
- await drive(byName, "memory_search", { query: "绝不存在的检索词-195" });
153
- await drive(byName, "memory_search", { query: "形态", mode: "keyword", limit: 2 });
154
- // occurred 时间窗分支(tools.js 的 occurredWindow 接线)
155
- await drive(byName, "memory_search", { query: "形态", occurred_from: "2026-01-01", occurred_to: "2026-12-31" });
156
- note("memory_search");
157
-
158
- // --- memory_list:默认 / 类型过滤 / 含归档 / 分页 / occurred 窗口 ---
159
- await drive(byName, "memory_list", {});
160
- await drive(byName, "memory_list", { type: "project", limit: 1, offset: 1 });
161
- await drive(byName, "memory_list", { include_archived: true });
162
- await drive(byName, "memory_list", { occurred_from: "2026-01-01", occurred_to: "2026-12-31" });
163
- note("memory_list");
164
-
165
- // --- memory_get:极简行 / 全量标注行(#184 的原始现场) ---
166
- await drive(byName, "memory_get", { id: plain.id });
167
- await drive(byName, "memory_get", { id: annotated.id });
168
- note("memory_get");
169
-
170
- // --- memory_update:字段修正 + 显式 scope 纠正(另一条会展开条件键的写路径) ---
171
- await drive(byName, "memory_update", { id: plain.id, title: "shape-0 renamed", importance: 4 });
172
- await drive(byName, "memory_update", {
173
- id: plain.id,
174
- content: "改过的正文",
175
- agent_scope: "global",
176
- workspace_scope: "proj-gamma",
177
- reason: "issue #195 矩阵覆盖"
178
- });
179
- note("memory_update");
180
-
181
- // --- memory_delete:删得掉 / 删不掉(不存在 → false,不抛错) ---
182
- const doomed = store.save({ type: "history", title: "doomed", content: "待删", source: "test" });
183
- const deleted = await drive(byName, "memory_delete", { id: doomed.id });
184
- assert.equal(deleted.deleted, true);
185
- const notDeleted = await drive(byName, "memory_delete", { id: "no-such-id-195" });
186
- assert.equal(notDeleted.deleted, false);
187
- note("memory_delete");
188
-
189
- // --- memory_forget / memory_archive:两个方向的分支 ---
190
- const forgetTarget = store.save({ type: "history", title: "forget-me", content: "待遗忘", source: "test" });
191
- assert.equal((await drive(byName, "memory_forget", { id: forgetTarget.id })).memory.forgotten, true);
192
- assert.equal((await drive(byName, "memory_forget", { id: forgetTarget.id, forgotten: false })).memory.forgotten, false);
193
- note("memory_forget");
194
-
195
- const archiveTarget = store.save({ type: "history", title: "archive-me", content: "待归档", source: "test" });
196
- assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id })).memory.archived, true);
197
- assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id, archived: false })).memory.archived, false);
198
- note("memory_archive");
199
-
200
- // --- memory_runtime:只驱动只读与「无载荷早退」两条分支 ---
201
- // provision 会联网下载数十至上百 MB(且可能 adopt 本机载荷、硬链接数以百 MB 计),
202
- // verify 命中载荷时会真实加载模型——两者都不属于单元测试该做的事。status 与其
203
- // 「无载荷」早退分支已覆盖该 schema 的 summary/status/cost/payloadId/packages/reason
204
- // 六个键;provision 侧的 strategy/files/bytes 与 verify 侧的 dimension 由 C 层
205
- // 结构断言兜底(声明合规),分支行为本身留给 e2e/人工验证。
206
- const emptyRuntimeDir = mkdtempSync(join(tmpdir(), "mneme-195-runtime-"));
207
- try {
208
- const rt = setup({ runtimeDir: emptyRuntimeDir });
209
- await drive(rt.byName, "memory_runtime", { action: "status" });
210
- await drive(rt.byName, "memory_runtime", { action: "verify" });
211
- assert.ok(service, "service 保留引用,避免未使用变量告警");
212
- note("memory_runtime");
213
- } finally {
214
- rmSync(emptyRuntimeDir, { recursive: true, force: true });
215
- }
216
-
217
- // --- memory_register_document(#230):created 分支 + evidence 求交(含丢弃)。
218
- // superseded 分支由 document.test.js 覆盖;flag 关闭的拒绝路径同样在那边。
219
- const docDir = mkdtempSync(join(tmpdir(), "mneme-230-doc-"));
220
- const docFile = join(docDir, "report.md");
221
- try {
222
- writeFileSync(docFile, "# report\n\nbody", "utf8");
223
- const reg = await drive(byName, "memory_register_document", {
224
- path: docFile,
225
- title: "matrix-doc",
226
- summary: "指针行摘要",
227
- tags: ["matrix"],
228
- evidence: [plain.id, "fabricated-230"]
229
- });
230
- assert.equal(reg.action, "created");
231
- assert.equal(reg.evidence_kept, 1);
232
- assert.equal(reg.evidence_dropped, 1);
233
- assert.equal(reg.degraded, true);
234
- note("memory_register_document");
235
- } finally {
236
- rmSync(docDir, { recursive: true, force: true });
237
- }
238
-
239
- assert.deepEqual([...driven].sort(), [...TOOL_NAMES].sort(), "每个工具都必须至少被驱动一次(矩阵不允许空缺)");
240
- });
241
-
242
- test("A' 未命中 id 的三个工具按契约抛错(而不是返回半截对象)", async () => {
243
- const { byName } = setup();
244
- await assert.rejects(() => byName.memory_get.execute({ id: "missing-195" }), /memory not found/);
245
- await assert.rejects(() => byName.memory_forget.execute({ id: "missing-195" }), /memory not found/);
246
- await assert.rejects(() => byName.memory_archive.execute({ id: "missing-195" }), /memory not found/);
247
- });
248
-
249
- test("B DTO 产地:toApiList 全形态输出 ⊆ MEMORY_ITEM_SCHEMA,且声明键全部被真实产出", () => {
250
- const { store, service } = setup();
251
- seedShapes(store);
252
- const rows = service.toApiList(service.list({ limit: 100 }));
253
- assert.ok(rows.length >= 5, "种子行应当全部可读回");
254
-
255
- const observed = new Set();
256
- for (const dto of rows) {
257
- const errors = validateJsonSchemaValue(MEMORY_ITEM_SCHEMA, dto);
258
- assert.deepEqual(errors, [], `toApiList 产出的 DTO 未被 MEMORY_ITEM_SCHEMA 覆盖:${JSON.stringify(errors)}`);
259
- for (const key of Object.keys(dto)) observed.add(key);
260
- }
261
-
262
- // 反向:声明的每个键都必须有形态真的产出它。这条让 schema 与种子互相咬合——
263
- // 以后给 toApiList 增键却忘了补种子,会在这里失败并提醒补形态。
264
- const declared = Object.keys(MEMORY_ITEM_SCHEMA.properties);
265
- const neverProduced = declared.filter((key) => !observed.has(key));
266
- assert.deepEqual(neverProduced, [], `MEMORY_ITEM_SCHEMA 声明了但矩阵从未产出的键(补种子形态或删死声明):${neverProduced.join(", ")}`);
267
- });
268
-
269
- test("B' search / list / get 三个消费方共用同一份 item schema(#184 的根因是有人另抄了一份)", () => {
270
- const { byName } = setup();
271
- const itemSchemaOf = (name) => {
272
- const props = byName[name].output.schema.properties;
273
- return name === "memory_get" ? props.memory : props.items.items;
274
- };
275
- const search = itemSchemaOf("memory_search");
276
- const list = itemSchemaOf("memory_list");
277
- const get = itemSchemaOf("memory_get");
278
- const keys = (s) => Object.keys(s.properties).sort();
279
-
280
- assert.deepEqual(keys(search), keys(MEMORY_ITEM_SCHEMA));
281
- assert.deepEqual(keys(list), keys(search));
282
- assert.deepEqual(keys(get), keys(search));
283
- for (const [name, schema] of [["search", search], ["list", list], ["get", get]]) {
284
- assert.equal(schema.additionalProperties, false, `${name} 的 item schema 必须闭合`);
285
- }
286
- });
287
-
288
- test("C 结构:全部 10 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
289
- const { byName } = setup();
290
- for (const name of TOOL_NAMES) {
291
- const schema = byName[name].output.schema;
292
- assert.ok(schema, `${name} 必须声明 output.schema`);
293
- assert.equal(schema.additionalProperties, false, `${name} 的 schema 必须闭合(否则未声明键不会被拒收)`);
294
- assert.equal(schema.type, "object", `${name} 的顶层 schema 必须是 object`);
295
- for (const key of schema.required ?? []) {
296
- assert.ok(schema.properties?.[key], `${name}.required 里的 ${key} 不在 properties 中`);
297
- }
298
- for (const [key, prop] of Object.entries(schema.properties ?? {})) {
299
- assert.ok(prop.type, `${name}.${key} 缺 type——没有 type 的声明校验器无法真正约束它`);
300
- }
301
- }
302
- });
303
-
304
- test("D 负例锁:注入未声明键时校验器必须报错(否则 A/B/C 三层都是空转)", () => {
305
- const { store, service, byName } = setup();
306
- const row = store.save({ type: "project", title: "negative-control", content: "负例", source: "test" });
307
- const dto = service.toApiList([store.getById(row.id)])[0];
308
-
309
- // 正例:干净 DTO 必须通过
310
- assert.deepEqual(validateJsonSchemaValue(MEMORY_ITEM_SCHEMA, dto), []);
311
- assert.deepEqual(validateJsonSchemaValue(byName.memory_get.output.schema, { memory: dto }), []);
312
-
313
- // 负例:多一个键就必须被拒——这正是 #184 的形状
314
- const poisoned = { ...dto, agent_scope_source_new: "auto" };
315
- assert.notDeepEqual(
316
- validateJsonSchemaValue(MEMORY_ITEM_SCHEMA, poisoned),
317
- [],
318
- "未声明键必须被拒收:#184 类漂移就是靠这条被抓出来的"
319
- );
320
- assert.notDeepEqual(
321
- validateJsonSchemaValue(byName.memory_get.output.schema, { memory: poisoned }),
322
- [],
323
- "memory_get 的 schema 同样必须拒收未声明键"
324
- );
325
- });
1
+ // issue #195:全工具矩阵的「DTO 键集 ⊆ output schema」系统性断言——防 #184 类漂移。
2
+ //
3
+ // 背景:#184 是 memory_get 的内联 output schema 漏声明了 v0.8.1 新增的 scope 来源
4
+ // 三键,而 schema 是 `additionalProperties: false`——toApiList 条件展开的键一旦没被
5
+ // 声明,宿主 in-process 校验直接拒收("invalid output"),表现为「个别 id 必失败、
6
+ // 多数正常」。单点回归(tools-memory-get-schema.test.js)只护住了 memory_get 一个
7
+ // 工具:换一个工具、换一个键,同类事故可以原样重演。
8
+ //
9
+ // 这里把护栏提到矩阵层,四层断言各管一段:
10
+ // A 实跑:9 个工具每个可安全触达的分支,拿**真实 execute 的返回值**过生产同款校验器
11
+ // (validateJsonSchemaValue)——不写手抄的期望值,让运行时自己说话;
12
+ // B 产地:DTO 的唯一产地 service.toApiList 在全形态数据下的输出 ⊆ MEMORY_ITEM_SCHEMA,
13
+ // 并反向要求声明里的每个键都被至少一种形态真实产出(声明了却永不出现的键=死声明,
14
+ // 会在下一次 schema 增键时立刻暴露);
15
+ // C 结构:每个工具的 schema 必须闭合(additionalProperties:false)、required ⊆ properties、
16
+ // 每项都带 type——否则 A/B 两层会因为校验器形同虚设而静默失效;
17
+ // D 负例锁:故意注入一个未声明键,断言校验器必须报错。没有这一层,上面三层可以一起
18
+ // 绿着却毫无保护力(校验器被换掉、schema 被放开都会静默通过)。
19
+ import test from "node:test";
20
+ import assert from "node:assert/strict";
21
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
+ import { tmpdir } from "node:os";
23
+ import { join } from "node:path";
24
+ import { validateJsonSchemaValue } from "@deepseek-ai/dsh-tools";
25
+ import { createStore } from "../src/store.js";
26
+ import { createService } from "../src/service.js";
27
+ import { createTools, MEMORY_ITEM_SCHEMA } from "../src/tools.js";
28
+
29
+ // 矩阵清单:新增工具必须同时补进本文件的分支表,否则下面第一条断言会先失败提醒
30
+ // ——这是「系统性」的含义:漏一个工具不会静默通过。
31
+ const TOOL_NAMES = [
32
+ "memory_save",
33
+ "memory_search",
34
+ "memory_list",
35
+ "memory_get",
36
+ "memory_update",
37
+ "memory_delete",
38
+ "memory_forget",
39
+ "memory_archive",
40
+ "memory_register_document",
41
+ "memory_runtime"
42
+ ];
43
+
44
+ function setup(config = {}) {
45
+ const store = createStore(":memory:");
46
+ // config 同时喂 service 与 tools:#230 起 registerDocument 的闸在 service
47
+ // 侧读同一个 config,只喂 tools 会让注册工具在矩阵里永远抛 disabled。
48
+ const service = createService({ store, mirror: null, config });
49
+ const registered = [];
50
+ const ctx = {
51
+ tools: {
52
+ register(def) {
53
+ registered.push(def);
54
+ return () => {};
55
+ }
56
+ }
57
+ };
58
+ createTools(ctx, service, config, undefined);
59
+ const byName = Object.fromEntries(registered.map((def) => [def.name, def]));
60
+ return { store, service, byName };
61
+ }
62
+
63
+ // 驱动一个工具并立刻用它自己声明的 schema 校验真实返回值。
64
+ // 返回 { value }:调用方需要时再对值做语义断言。
65
+ async function drive(byName, name, args = [], exec) {
66
+ const tool = byName[name];
67
+ assert.ok(tool, `工具 ${name} 未注册`);
68
+ const value = await tool.execute(args, exec);
69
+ assertMatches(byName, name, value, `args=${JSON.stringify(args)}`);
70
+ return value;
71
+ }
72
+
73
+ // 真实返回值必须被声明 schema 完全覆盖;失败时把违规明细一起抛出,定位更快。
74
+ function assertMatches(byName, name, value, label) {
75
+ const errors = validateJsonSchemaValue(byName[name].output.schema, value);
76
+ assert.deepEqual(
77
+ errors,
78
+ [],
79
+ `${name} 的真实返回未被 output schema 覆盖(${label}):${JSON.stringify(errors)}\n返回键:${JSON.stringify(Object.keys(value ?? {}))}`
80
+ );
81
+ }
82
+
83
+ // 全形态种子:toApiList 的 7 个条件键只在对应列非 undefined 时展开,所以每种形态
84
+ // 都要真实落一行,否则 B 层会(正确地)报「声明了却从未产出」。
85
+ function seedShapes(store) {
86
+ const rows = [];
87
+ const push = (over) => {
88
+ const memory = {
89
+ type: "project",
90
+ title: `shape-${rows.length}`,
91
+ content: `形态 ${rows.length} 的正文`,
92
+ source: "test",
93
+ ...over
94
+ };
95
+ // #230:document 走存储层唯一铸造口(通用 save 拒绝 document)。
96
+ rows.push(memory.type === "document" ? store.saveDocument(memory) : store.save(memory));
97
+ };
98
+ push({}); // 极简形态:只有基础键
99
+ push({ sensitivity: "personal" });
100
+ push({ occurred_at: "2026-09-15T00:00:00Z" });
101
+ push({ agent_scope: "coder", workspace_scope: "proj-alpha" });
102
+ push({
103
+ // 全量标注:#184 的原始触发形态
104
+ agent_scope: "coder",
105
+ agent_scope_source: "auto",
106
+ workspace_scope: "proj-alpha",
107
+ workspace_scope_source: "explicit",
108
+ scope_decided_at: "2026-09-15T00:00:00Z",
109
+ sensitivity: "personal",
110
+ occurred_at: "2026-09-14T12:00:00Z",
111
+ tags: ["shape", "annotated"],
112
+ importance: 5
113
+ });
114
+ // #230:document 指针行——doc_path 是 toApiList 的条件键,必须有一行真的产出它。
115
+ push({ type: "document", title: "shape-doc", content: "指针行摘要", doc_path: "X:\\seed\\doc.md", evidence: [] });
116
+ return rows;
117
+ }
118
+
119
+ test("工具矩阵与 TOOL_NAMES 清单一致(新增工具必须补进矩阵)", () => {
120
+ const { byName } = setup();
121
+ assert.deepEqual(Object.keys(byName).sort(), [...TOOL_NAMES].sort());
122
+ });
123
+
124
+ test("A 实跑:10 个工具每个可安全触达的分支,真实返回 ⊆ 声明的 output schema", async () => {
125
+ const { store, service, byName } = setup({ documentMemoryEnabled: true });
126
+ const shapes = seedShapes(store);
127
+ const annotated = shapes[shapes.length - 1];
128
+ const plain = shapes[0];
129
+ const driven = new Set();
130
+
131
+ const note = (name) => driven.add(name);
132
+
133
+ // --- memory_save:created 与 merged 两条分支 ---
134
+ await drive(byName, "memory_save", { type: "project", title: "save-new", content: "首次写入" });
135
+ note("memory_save");
136
+ await drive(byName, "memory_save", { type: "project", title: "save-new", content: "同键再写 → merged" });
137
+ // 显式 scope + 敏感度 + 事件时间:把「条件展开的键」真的写进库(#184 的真实入口)
138
+ const scopedSave = await drive(byName, "memory_save", {
139
+ type: "preference",
140
+ title: "save-scoped",
141
+ content: "带显式 scope 的写入",
142
+ agent_scope: "global",
143
+ workspace_scope: "proj-beta",
144
+ sensitivity: "personal",
145
+ occurred_at: "2026-09-16T00:00:00Z"
146
+ });
147
+ assert.ok(scopedSave.id, "memory_save 必须回传 id");
148
+
149
+ // --- memory_search:命中 / 空结果 / 显式 keyword 档 ---
150
+ const hit = await drive(byName, "memory_search", { query: "形态" });
151
+ assert.ok(hit.items.length > 0, "关键词检索应当命中种子行");
152
+ await drive(byName, "memory_search", { query: "绝不存在的检索词-195" });
153
+ await drive(byName, "memory_search", { query: "形态", mode: "keyword", limit: 2 });
154
+ // occurred 时间窗分支(tools.js 的 occurredWindow 接线)
155
+ await drive(byName, "memory_search", { query: "形态", occurred_from: "2026-01-01", occurred_to: "2026-12-31" });
156
+ note("memory_search");
157
+
158
+ // --- memory_list:默认 / 类型过滤 / 含归档 / 分页 / occurred 窗口 ---
159
+ await drive(byName, "memory_list", {});
160
+ await drive(byName, "memory_list", { type: "project", limit: 1, offset: 1 });
161
+ await drive(byName, "memory_list", { include_archived: true });
162
+ await drive(byName, "memory_list", { occurred_from: "2026-01-01", occurred_to: "2026-12-31" });
163
+ note("memory_list");
164
+
165
+ // --- memory_get:极简行 / 全量标注行(#184 的原始现场) ---
166
+ await drive(byName, "memory_get", { id: plain.id });
167
+ await drive(byName, "memory_get", { id: annotated.id });
168
+ note("memory_get");
169
+
170
+ // --- memory_update:字段修正 + 显式 scope 纠正(另一条会展开条件键的写路径) ---
171
+ await drive(byName, "memory_update", { id: plain.id, title: "shape-0 renamed", importance: 4 });
172
+ await drive(byName, "memory_update", {
173
+ id: plain.id,
174
+ content: "改过的正文",
175
+ agent_scope: "global",
176
+ workspace_scope: "proj-gamma",
177
+ reason: "issue #195 矩阵覆盖"
178
+ });
179
+ note("memory_update");
180
+
181
+ // --- memory_delete:删得掉 / 删不掉(不存在 → false,不抛错) ---
182
+ const doomed = store.save({ type: "history", title: "doomed", content: "待删", source: "test" });
183
+ const deleted = await drive(byName, "memory_delete", { id: doomed.id });
184
+ assert.equal(deleted.deleted, true);
185
+ const notDeleted = await drive(byName, "memory_delete", { id: "no-such-id-195" });
186
+ assert.equal(notDeleted.deleted, false);
187
+ note("memory_delete");
188
+
189
+ // --- memory_forget / memory_archive:两个方向的分支 ---
190
+ const forgetTarget = store.save({ type: "history", title: "forget-me", content: "待遗忘", source: "test" });
191
+ assert.equal((await drive(byName, "memory_forget", { id: forgetTarget.id })).memory.forgotten, true);
192
+ assert.equal((await drive(byName, "memory_forget", { id: forgetTarget.id, forgotten: false })).memory.forgotten, false);
193
+ note("memory_forget");
194
+
195
+ const archiveTarget = store.save({ type: "history", title: "archive-me", content: "待归档", source: "test" });
196
+ assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id })).memory.archived, true);
197
+ assert.equal((await drive(byName, "memory_archive", { id: archiveTarget.id, archived: false })).memory.archived, false);
198
+ note("memory_archive");
199
+
200
+ // --- memory_runtime:只驱动只读与「无载荷早退」两条分支 ---
201
+ // provision 会联网下载数十至上百 MB(且可能 adopt 本机载荷、硬链接数以百 MB 计),
202
+ // verify 命中载荷时会真实加载模型——两者都不属于单元测试该做的事。status 与其
203
+ // 「无载荷」早退分支已覆盖该 schema 的 summary/status/cost/payloadId/packages/reason
204
+ // 六个键;provision 侧的 strategy/files/bytes 与 verify 侧的 dimension 由 C 层
205
+ // 结构断言兜底(声明合规),分支行为本身留给 e2e/人工验证。
206
+ const emptyRuntimeDir = mkdtempSync(join(tmpdir(), "mneme-195-runtime-"));
207
+ try {
208
+ const rt = setup({ runtimeDir: emptyRuntimeDir });
209
+ await drive(rt.byName, "memory_runtime", { action: "status" });
210
+ await drive(rt.byName, "memory_runtime", { action: "verify" });
211
+ assert.ok(service, "service 保留引用,避免未使用变量告警");
212
+ note("memory_runtime");
213
+ } finally {
214
+ rmSync(emptyRuntimeDir, { recursive: true, force: true });
215
+ }
216
+
217
+ // --- memory_register_document(#230):created 分支 + evidence 求交(含丢弃)。
218
+ // superseded 分支由 document.test.js 覆盖;flag 关闭的拒绝路径同样在那边。
219
+ const docDir = mkdtempSync(join(tmpdir(), "mneme-230-doc-"));
220
+ const docFile = join(docDir, "report.md");
221
+ try {
222
+ writeFileSync(docFile, "# report\n\nbody", "utf8");
223
+ const reg = await drive(byName, "memory_register_document", {
224
+ path: docFile,
225
+ title: "matrix-doc",
226
+ summary: "指针行摘要",
227
+ tags: ["matrix"],
228
+ evidence: [plain.id, "fabricated-230"]
229
+ });
230
+ assert.equal(reg.action, "created");
231
+ assert.equal(reg.evidence_kept, 1);
232
+ assert.equal(reg.evidence_dropped, 1);
233
+ assert.equal(reg.degraded, true);
234
+ note("memory_register_document");
235
+ } finally {
236
+ rmSync(docDir, { recursive: true, force: true });
237
+ }
238
+
239
+ assert.deepEqual([...driven].sort(), [...TOOL_NAMES].sort(), "每个工具都必须至少被驱动一次(矩阵不允许空缺)");
240
+ });
241
+
242
+ test("A' 未命中 id 的三个工具按契约抛错(而不是返回半截对象)", async () => {
243
+ const { byName } = setup();
244
+ await assert.rejects(() => byName.memory_get.execute({ id: "missing-195" }), /memory not found/);
245
+ await assert.rejects(() => byName.memory_forget.execute({ id: "missing-195" }), /memory not found/);
246
+ await assert.rejects(() => byName.memory_archive.execute({ id: "missing-195" }), /memory not found/);
247
+ });
248
+
249
+ test("B DTO 产地:toApiList 全形态输出 ⊆ MEMORY_ITEM_SCHEMA,且声明键全部被真实产出", () => {
250
+ const { store, service } = setup();
251
+ seedShapes(store);
252
+ const rows = service.toApiList(service.list({ limit: 100 }));
253
+ assert.ok(rows.length >= 5, "种子行应当全部可读回");
254
+
255
+ const observed = new Set();
256
+ for (const dto of rows) {
257
+ const errors = validateJsonSchemaValue(MEMORY_ITEM_SCHEMA, dto);
258
+ assert.deepEqual(errors, [], `toApiList 产出的 DTO 未被 MEMORY_ITEM_SCHEMA 覆盖:${JSON.stringify(errors)}`);
259
+ for (const key of Object.keys(dto)) observed.add(key);
260
+ }
261
+
262
+ // 反向:声明的每个键都必须有形态真的产出它。这条让 schema 与种子互相咬合——
263
+ // 以后给 toApiList 增键却忘了补种子,会在这里失败并提醒补形态。
264
+ const declared = Object.keys(MEMORY_ITEM_SCHEMA.properties);
265
+ const neverProduced = declared.filter((key) => !observed.has(key));
266
+ assert.deepEqual(neverProduced, [], `MEMORY_ITEM_SCHEMA 声明了但矩阵从未产出的键(补种子形态或删死声明):${neverProduced.join(", ")}`);
267
+ });
268
+
269
+ test("B' search / list / get 三个消费方共用同一份 item schema(#184 的根因是有人另抄了一份)", () => {
270
+ const { byName } = setup();
271
+ const itemSchemaOf = (name) => {
272
+ const props = byName[name].output.schema.properties;
273
+ return name === "memory_get" ? props.memory : props.items.items;
274
+ };
275
+ const search = itemSchemaOf("memory_search");
276
+ const list = itemSchemaOf("memory_list");
277
+ const get = itemSchemaOf("memory_get");
278
+ const keys = (s) => Object.keys(s.properties).sort();
279
+
280
+ assert.deepEqual(keys(search), keys(MEMORY_ITEM_SCHEMA));
281
+ assert.deepEqual(keys(list), keys(search));
282
+ assert.deepEqual(keys(get), keys(search));
283
+ for (const [name, schema] of [["search", search], ["list", list], ["get", get]]) {
284
+ assert.equal(schema.additionalProperties, false, `${name} 的 item schema 必须闭合`);
285
+ }
286
+ });
287
+
288
+ test("C 结构:全部 10 个工具的 output schema 闭合、required ⊆ properties、每项带 type", () => {
289
+ const { byName } = setup();
290
+ for (const name of TOOL_NAMES) {
291
+ const schema = byName[name].output.schema;
292
+ assert.ok(schema, `${name} 必须声明 output.schema`);
293
+ assert.equal(schema.additionalProperties, false, `${name} 的 schema 必须闭合(否则未声明键不会被拒收)`);
294
+ assert.equal(schema.type, "object", `${name} 的顶层 schema 必须是 object`);
295
+ for (const key of schema.required ?? []) {
296
+ assert.ok(schema.properties?.[key], `${name}.required 里的 ${key} 不在 properties 中`);
297
+ }
298
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
299
+ assert.ok(prop.type, `${name}.${key} 缺 type——没有 type 的声明校验器无法真正约束它`);
300
+ }
301
+ }
302
+ });
303
+
304
+ test("D 负例锁:注入未声明键时校验器必须报错(否则 A/B/C 三层都是空转)", () => {
305
+ const { store, service, byName } = setup();
306
+ const row = store.save({ type: "project", title: "negative-control", content: "负例", source: "test" });
307
+ const dto = service.toApiList([store.getById(row.id)])[0];
308
+
309
+ // 正例:干净 DTO 必须通过
310
+ assert.deepEqual(validateJsonSchemaValue(MEMORY_ITEM_SCHEMA, dto), []);
311
+ assert.deepEqual(validateJsonSchemaValue(byName.memory_get.output.schema, { memory: dto }), []);
312
+
313
+ // 负例:多一个键就必须被拒——这正是 #184 的形状
314
+ const poisoned = { ...dto, agent_scope_source_new: "auto" };
315
+ assert.notDeepEqual(
316
+ validateJsonSchemaValue(MEMORY_ITEM_SCHEMA, poisoned),
317
+ [],
318
+ "未声明键必须被拒收:#184 类漂移就是靠这条被抓出来的"
319
+ );
320
+ assert.notDeepEqual(
321
+ validateJsonSchemaValue(byName.memory_get.output.schema, { memory: poisoned }),
322
+ [],
323
+ "memory_get 的 schema 同样必须拒收未声明键"
324
+ );
325
+ });