@modusensus/dsh-mneme 0.13.0 → 0.14.0-beta.1

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 (65) hide show
  1. package/README.md +6 -4
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +5 -3
  4. package/lib/api.js +211 -5
  5. package/lib/client.js +1240 -354
  6. package/lib/config.js +27 -0
  7. package/lib/dream/decisions.js +39 -1
  8. package/lib/dream/sleep.js +146 -6
  9. package/lib/dream/triage.js +139 -0
  10. package/lib/dream.js +123 -8
  11. package/lib/hot-memory.js +14 -2
  12. package/lib/index.js +31 -1
  13. package/lib/inject.js +14 -2
  14. package/lib/lang.js +11 -0
  15. package/lib/service.js +76 -4
  16. package/lib/settings.js +16 -1
  17. package/lib/store.js +238 -5
  18. package/lib/wiki/claims.js +267 -0
  19. package/lib/wiki/queries.js +168 -2
  20. package/lib/wiki/render.js +74 -0
  21. package/lib/wiki/t0.js +43 -6
  22. package/lib/wiki/t1.js +138 -31
  23. package/package.json +15 -1
  24. package/scripts/release-docs.mjs +7 -1
  25. package/scripts/release-notes.mjs +4 -1
  26. package/src/api.js +211 -5
  27. package/src/config.js +27 -0
  28. package/src/dream/decisions.js +39 -1
  29. package/src/dream/sleep.js +146 -6
  30. package/src/dream/triage.js +139 -0
  31. package/src/dream.js +123 -8
  32. package/src/hot-memory.js +14 -2
  33. package/src/index.js +31 -1
  34. package/src/inject.js +14 -2
  35. package/src/lang.js +11 -0
  36. package/src/service.js +76 -4
  37. package/src/settings.js +16 -1
  38. package/src/store.js +238 -5
  39. package/src/wiki/claims.js +267 -0
  40. package/src/wiki/queries.js +168 -2
  41. package/src/wiki/render.js +74 -0
  42. package/src/wiki/t0.js +43 -6
  43. package/src/wiki/t1.js +138 -31
  44. package/test/api.test.js +11 -1
  45. package/test/client.test.js +275 -34
  46. package/test/conflict-ledger-enforce.test.js +452 -0
  47. package/test/conflict-ledger.test.js +242 -0
  48. package/test/contrast.test.js +124 -12
  49. package/test/dream-baseline-clamp.test.js +174 -0
  50. package/test/graph-api.test.js +124 -0
  51. package/test/hot-memory.test.js +48 -0
  52. package/test/inject-parent-gate.test.js +6 -3
  53. package/test/inject.test.js +59 -0
  54. package/test/panel-chart-scale.test.js +90 -0
  55. package/test/panel-tokens.test.js +245 -0
  56. package/test/release-docs.test.js +20 -0
  57. package/test/release-notes.test.js +25 -0
  58. package/test/security-doc.test.js +10 -0
  59. package/test/sleep.test.js +37 -0
  60. package/test/wiki-api.test.js +110 -3
  61. package/test/wiki-claims.test.js +194 -0
  62. package/test/wiki-provenance.test.js +202 -0
  63. package/test/wiki-queries.test.js +121 -0
  64. package/test/wiki-t0.test.js +77 -1
  65. package/test/wiki-t1.test.js +226 -1
@@ -207,3 +207,127 @@ test("ego 2-hop traversal work is bounded by limit, not by graph size (#409)", a
207
207
  // 250ms(≈4× 实测最坏值)只为兜住数量级级别的性能崩塌(#409)。
208
208
  assert.ok(ms < 250, `2-hop query took ${ms.toFixed(1)}ms — the work bound above is the lock; 250ms is only an outlier guard`);
209
209
  });
210
+
211
+ // #619 可见化 B:情感边的 metadata 必须原样出 API。
212
+ // 这条边上的全部信息量(情绪 / 谁对谁 / 几个场合 / 几条证据)都只存在 metadata 里,
213
+ // 上一版这里只映射 id/from/to/relation_type/memory_id/created_at/weight/source,于是
214
+ // 数据在库里有、在 store.toRelation 里也有,却在最后一跳被丢掉——面板上看起来就是
215
+ // 「这个功能没做」。所以这里钉的不是「字段存在」,而是「一个不认识的字段也能穿过去」。
216
+ test("#619 ego edges carry relation metadata through untouched", async () => {
217
+ const { routes, service } = setup();
218
+ const person = service.createEntity({ name: "桉桉", type: "person" });
219
+ const target = service.createEntity({ name: "答辩", type: "concept" });
220
+ const meta = {
221
+ emotion: "焦虑",
222
+ attribution: "occasion",
223
+ why: "答辩前一晚睡不着",
224
+ evidence: ["m-1", "m-2"],
225
+ sessionCount: 2,
226
+ model: "mock:affective-model",
227
+ runId: "run-1",
228
+ // 故意塞一个现在没人认识的字段:API 层不许按白名单裁剪,否则下一个新字段
229
+ // 又会「写进去了但看不见」,这正是这一轮要修的那类静默。
230
+ futureField: { nested: [1, 2, 3] }
231
+ };
232
+ service.saveRelation({
233
+ from_entity: person.id,
234
+ to_entity: target.id,
235
+ relation_type: "affective_context",
236
+ memory_id: "m-1",
237
+ source: "llm",
238
+ metadata: meta
239
+ });
240
+
241
+ const res = new FakeRes();
242
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/ego")
243
+ .handler(req("/api/dsh-mneme/semantic/graph/ego?entity=桉桉&depth=1"), res);
244
+ assert.equal(res.statusCode, 200);
245
+ const edge = JSON.parse(res.body).edges.find((e) => e.relation_type === "affective_context");
246
+ assert.ok(edge, "the affective edge is on the ego graph");
247
+ assert.deepEqual(edge.metadata, meta, "metadata passes through field for field, including unknown ones");
248
+ });
249
+
250
+ // 反方向同样要钉:没有 metadata 的边给 null,不给 {}。
251
+ // 前端要靠这个区分「老边本来就没有情绪」和「有 metadata 但情绪字段丢了」——
252
+ // 给空对象会让两种情形在界面上长得一模一样。
253
+ test("#619 ego edges without metadata report null, not an empty object", async () => {
254
+ const { routes, service } = setup();
255
+ const map = buildChain(service, ["A", "B"]);
256
+ assert.ok(map.size === 2);
257
+ const res = new FakeRes();
258
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/ego")
259
+ .handler(req("/api/dsh-mneme/semantic/graph/ego?entity=A&depth=1"), res);
260
+ assert.equal(res.statusCode, 200);
261
+ const edge = JSON.parse(res.body).edges[0];
262
+ assert.equal(edge.metadata, null, "absent metadata is null so the UI can tell it apart from a populated one");
263
+ });
264
+
265
+ // #619 可见化 C:情绪归因一览。ego 只能从某个实体往外看,回答不了「库里有
266
+ // 哪些情绪」。这条路由是全局的反向视角,三条断言各自钉一件会骗人的事。
267
+ test("#619 affective overview groups by person, target, emotion and keeps occasion apart from trait", async () => {
268
+ const { routes, service } = setup();
269
+ const person = service.createEntity({ name: "桉桉", type: "person" });
270
+ const defense = service.createEntity({ name: "答辩", type: "concept" });
271
+ const interview = service.createEntity({ name: "面试", type: "concept" });
272
+ // 同一情绪、两个对象、两个归属档:四组,不能被合掉任何一组。
273
+ service.saveRelation({
274
+ from_entity: person.id, to_entity: defense.id, relation_type: "affective_context",
275
+ memory_id: "m-1", source: "llm",
276
+ metadata: { emotion: "焦虑", attribution: "occasion", sessionCount: 1, evidence: ["m-1"] }
277
+ });
278
+ service.saveRelation({
279
+ from_entity: person.id, to_entity: interview.id, relation_type: "affective_context",
280
+ memory_id: "m-2", source: "llm",
281
+ metadata: { emotion: "焦虑", attribution: "occasion", sessionCount: 1, evidence: ["m-2"] }
282
+ });
283
+ service.saveRelation({
284
+ from_entity: person.id, to_entity: defense.id, relation_type: "affective_context",
285
+ memory_id: "m-3", source: "llm",
286
+ metadata: {
287
+ emotion: "焦虑", attribution: "trait", sessionCount: 1, traitSessionCount: 3,
288
+ promotedToTrait: true, evidence: ["m-1", "m-3", "m-4"]
289
+ }
290
+ });
291
+ // 没有情绪名的边:不进聚合,但要被计入 skippedEdges 而不是消失。
292
+ service.saveRelation({
293
+ from_entity: person.id, to_entity: defense.id, relation_type: "affective_context",
294
+ memory_id: "m-5", source: "llm", metadata: { attribution: "occasion" }
295
+ });
296
+ // 别的类型不受影响。
297
+ service.saveRelation({ from_entity: person.id, to_entity: defense.id, relation_type: "uses" });
298
+
299
+ const res = new FakeRes();
300
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/affective")
301
+ .handler(req("/api/dsh-mneme/semantic/graph/affective"), res);
302
+ assert.equal(res.statusCode, 200);
303
+ const data = JSON.parse(res.body);
304
+ assert.equal(data.items.length, 3, "两个对象 × occasion + 一个 trait = 三组");
305
+ assert.equal(data.skippedEdges, 1, "没有情绪名的边要单独计数,不能静默消失");
306
+
307
+ const trait = data.items.find((i) => i.attribution === "trait");
308
+ assert.ok(trait, "trait 组必须存在");
309
+ assert.equal(trait.person, "桉桉");
310
+ assert.equal(trait.target, "答辩");
311
+ assert.equal(trait.traitSessionCount, 3, "trait 要能说出跨了几个独立会话");
312
+ assert.equal(trait.promotedToTrait, true);
313
+ assert.equal(data.items[0].attribution, "trait", "trait 排在 occasion 前面");
314
+
315
+ const occasions = data.items.filter((i) => i.attribution === "occasion");
316
+ assert.equal(occasions.length, 2);
317
+ assert.deepEqual(occasions.map((o) => o.target).sort(), ["答辩", "面试"],
318
+ "同一情绪对不同对象是两组,不能被合并");
319
+ });
320
+
321
+ // 读不到的时候必须是 500,不能给空列表——空列表在面板上跟「你从没记过情绪」
322
+ // 长得一模一样,而这两件事对用户的含义正好相反。
323
+ test("#619 affective overview reports 500 when the store cannot answer", async () => {
324
+ const { routes, service } = setup();
325
+ const original = service.relationsByType;
326
+ service.relationsByType = undefined;
327
+ const res = new FakeRes();
328
+ await graphRoute(routes, "/api/dsh-mneme/semantic/graph/affective")
329
+ .handler(req("/api/dsh-mneme/semantic/graph/affective"), res);
330
+ assert.equal(res.statusCode, 500, "取不到就是取不到,不能假装是空的");
331
+ assert.equal(JSON.parse(res.body).error, "unavailable");
332
+ service.relationsByType = original;
333
+ });
@@ -209,3 +209,51 @@ test("roundsFromEvents with an empty surface yields no rounds (model sees nothin
209
209
  const events = [userEvent(1, "问题"), assistantEvent(2, "回答")];
210
210
  assert.equal(roundsFromEvents(events, 5, new Set()).length, 0);
211
211
  });
212
+
213
+ // --- Issue #637:omitPendingRound —— 注入侧不回放「正在组装的这一轮」 ---
214
+
215
+ test("omitPendingRound drops the trailing in-flight round (the #637 empty-A churn source)", () => {
216
+ // 本轮刚开了头(提问已落日志、回答还没来):默认行为渲染空 A: 行——
217
+ // 宿主「文本变了就追加快照」的契机会让回答落地后这一行抖成正文、再多追一份。
218
+ const events = [userEvent(1, "第一问"), assistantEvent(2, "第一答"), userEvent(3, "第二问")];
219
+ const rounds = roundsFromEvents(events, 5, null, { omitPendingRound: true });
220
+ assert.equal(rounds.length, 1, "the in-flight round is not replayed");
221
+ assert.equal(rounds[0].query, "第一问");
222
+ assert.ok(!JSON.stringify(rounds).includes("第二问"), "the current turn's query never reaches hot memory");
223
+ });
224
+
225
+ test("omitPendingRound keeps a closed round — the block changes exactly once per turn", () => {
226
+ // 回答落地(轮闭合)是热块在一轮内唯一该变化的时刻:此前所有组装看到同一份
227
+ // 文本,此刻变成「历史轮 + 刚闭合的轮」→ 宿主恰好追一份快照。
228
+ const open = [userEvent(1, "第一问"), assistantEvent(2, "第一答"), userEvent(3, "第二问")];
229
+ const closed = [userEvent(1, "第一问"), assistantEvent(2, "第一答"), userEvent(3, "第二问"), assistantEvent(4, "第二答")];
230
+ const before = roundsFromEvents(open, 5, null, { omitPendingRound: true });
231
+ const after = roundsFromEvents(closed, 5, null, { omitPendingRound: true });
232
+ assert.equal(before.length, 1);
233
+ assert.equal(after.length, 2);
234
+ assert.deepEqual(after[1], { query: "第二问", response: "第二答" });
235
+ // 轮与轮之间(下一轮开头的组装)块不再变:第三轮的提问此刻是 pending,被省略。
236
+ const nextTurn = roundsFromEvents([...closed, userEvent(5, "第三问")], 5, null, { omitPendingRound: true });
237
+ assert.deepEqual(nextTurn, after, "the block is stable across the turn boundary — no extra snapshot");
238
+ });
239
+
240
+ test("omitPendingRound only touches the tail — mid-log abandoned rounds still render empty A", () => {
241
+ // 双发消息(第二问把第一问顶掉):第一问是已被放弃的历史提问,渲染成空 A:
242
+ // 是既有语义,不属于「正在组装的这一轮」。
243
+ const events = [userEvent(1, "被顶掉的问题"), userEvent(2, "第二轮的问题"), assistantEvent(3, "第二轮的回答"), userEvent(4, "第三问")];
244
+ const rounds = roundsFromEvents(events, 5, null, { omitPendingRound: true });
245
+ assert.deepEqual(rounds, [
246
+ { query: "被顶掉的问题", response: "" },
247
+ { query: "第二轮的问题", response: "第二轮的回答" }
248
+ ]);
249
+ });
250
+
251
+ test("omitPendingRound works with the surface projection (rewind-shadowed tail not special)", () => {
252
+ // 撤回遮蔽过的事件不参与配对,也不影响「尾部 pending」的判定:可见面上最后
253
+ // 一条 user/message 是第二问,它才是本轮。
254
+ const events = [userEvent(1, "已被撤回的问题"), assistantEvent(2, "已被撤回的回答"), userEvent(3, "第一问"), assistantEvent(4, "第一答"), userEvent(5, "第二问")];
255
+ const visible = new Set([3, 4, 5]);
256
+ const rounds = roundsFromEvents(events, 5, visible, { omitPendingRound: true });
257
+ assert.deepEqual(rounds, [{ query: "第一问", response: "第一答" }]);
258
+ });
259
+
@@ -88,13 +88,16 @@ test("#249 第二批:面板的父子关系与后端同源(漏一处就静默
88
88
  // 渲染点:子项靠 flagRow 展开。只断言「声明与文案出现过」是恒真检查——把
89
89
  // 2236 行改回 g.items.map(boolRow),子项既不在 group.core 的 items 里、也不经
90
90
  // flagRow 渲染,面板上彻底消失,而字符串断言全绿。这一条才是那道门。
91
- assert.ok(/g\.items\.map\(flagRow\)/.test(client), "分组渲染必须走 flagRow,否则子项不渲染");
91
+ // #427 revamp:渲染形态从 map(flagRow) 升级为 map + subAfter(子块紧贴总闸),
92
+ // 门锁的是「每个 items 键都经 flagRow 渲染」这件事本身。
93
+ assert.ok(/g\.items\.map\(\(k\) => h\(react\.Fragment, \{ key: k \}, flagRow\(k\), subAfter\[k\]\)\)/.test(client),
94
+ "分组渲染必须走 flagRow(经 subAfter 内联),否则子项不渲染");
92
95
  const children = client.match(/const FEATURE_CHILDREN = (\{[^}]*\})/)?.[1] ?? "";
93
96
  assert.ok(children.includes("autoInject"), "面板未声明父开关 autoInject");
94
- const core = client.match(/\{ key: "group\.core", items: \[([^\]]*)\]/)?.[1] ?? "";
97
+ const allItems = [...client.matchAll(/items: \[([^\]]*)\]/g)].map((m) => m[1]).join("\n");
95
98
  for (const child of CHILDREN) {
96
99
  assert.ok(children.includes(`"${child}"`), `面板 FEATURE_CHILDREN 漏了 ${child}`);
97
- assert.ok(!core.includes(`"${child}"`), `${child} 同时在 items 与 FEATURE_CHILDREN 里,会被渲染两次`);
100
+ assert.ok(!allItems.includes(`"${child}"`), `${child} 同时在 items 与 FEATURE_CHILDREN 里,会被渲染两次`);
98
101
  // 双语文案:与 test/client.test.js 的路由键同款计数(zh + en 各一份 = 2)。
99
102
  const named = client.split(`"memory.features.${child}"`).length - 1;
100
103
  const hinted = client.split(`"memory.features.${child}.hint"`).length - 1;
@@ -155,6 +155,65 @@ test("Issue #129: 扁平的 data.content 形状仍被兼容", () => {
155
155
  assert.ok(text.includes("扁平形状的回答"), "flat content shape still supported");
156
156
  });
157
157
 
158
+ // ---- issue #637: 轮内快照洪泛 ----
159
+ // 宿主对动态 systemPrompt context 的契约是「文本变了就追加一份 runtime-context
160
+ // 快照,旧份永久留在会话历史」。热记忆此前把「正在组装的这一轮」也配对渲染:
161
+ // 提问先以空 A: 出现(与 prompt 里的真实 user message 重复一遍),回答落地后
162
+ // 这一行再抖成正文——同一轮内注入块至少变两次,报告实测 4 轮 → 10 份快照、
163
+ // 75% 重复率。注入侧省略尾部 pending 轮后,一轮内热块文本恒定,快照数降到
164
+ // 每轮一份(回答落地时恰好变一次)。
165
+
166
+ test("issue #637: the in-flight turn's query is not duplicated into the hot block", () => {
167
+ const { contexts } = setup();
168
+ // 本轮刚开头:上一轮已完成,本轮提问已落日志、回答还没来。
169
+ const session = {
170
+ id: "s1",
171
+ snapshotEvents: () => [
172
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第一问" }] } },
173
+ { type: "assistant/message", data: { message: { content: [{ type: "text", text: "第一答" }] } } },
174
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第二问" }] } }
175
+ ]
176
+ };
177
+ const text = contexts[0].text({ agent: { session } });
178
+ assert.ok(text.includes("第一问") && text.includes("第一答"), "completed previous round replays");
179
+ assert.ok(!text.includes("第二问"), "the current turn's query stays only in the real user message — no empty-A duplicate");
180
+ assert.ok(!/\nA:\s*\n/.test(text) && !text.includes("A: \n"), "no empty-A line in the rendered block");
181
+ });
182
+
183
+ test("issue #637: renders within one turn are byte-identical (the snapshot-flood lock)", () => {
184
+ const { contexts } = setup();
185
+ const log = [
186
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第一问" }] } },
187
+ { type: "assistant/message", data: { message: { content: [{ type: "text", text: "第一答" }] } } }
188
+ ];
189
+ const render = (events) => contexts[0].text({ agent: { session: { id: "s1", snapshotEvents: () => events } } });
190
+ // 本轮的第一次组装:提问已落日志(回答未落地)。
191
+ const first = render([...log, { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第二问" }] } }]);
192
+ // 工具循环中的再次组装:日志里多了 tool/assistant 中间事件,但本轮仍未闭合——
193
+ // 修复前这里已经是第二份快照(回答一落地就是第三份)。
194
+ const midTurn = render([...log,
195
+ { type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: "第二问" }] } },
196
+ { type: "tool/result", data: { message: { content: [{ type: "text", text: "工具输出" }] } } }
197
+ ]);
198
+ assert.equal(first, midTurn, "every assembly of the same turn renders the identical block");
199
+ assert.ok(first.length > 0, "the hot block still carries the completed previous round");
200
+ });
201
+
202
+ test("issue #637: the reply landing is the one change per turn; the next turn starts stable", () => {
203
+ const { contexts } = setup();
204
+ const render = (events) => contexts[0].text({ agent: { session: { id: "s1", snapshotEvents: () => events } } });
205
+ const u = (t) => ({ type: "user/message", data: { source: { kind: "user" }, content: [{ type: "text", text: t }] } });
206
+ const a = (t) => ({ type: "assistant/message", data: { message: { content: [{ type: "text", text: t }] } } });
207
+ const turnOpen = render([u("第一问")]);
208
+ const replyLanded = render([u("第一问"), a("第一答")]);
209
+ assert.ok(turnOpen === "", "the first turn renders no hot block (nothing completed yet)");
210
+ assert.ok(replyLanded.includes("第一问") && replyLanded.includes("第一答"), "the reply landing adds the round — the single snapshot of the turn");
211
+ // 下一轮开头:第三问此刻是 pending 被省略,块与上一轮收尾完全一致 → 宿主不再追快照。
212
+ const nextTurnOpen = render([u("第一问"), a("第一答"), u("第二问")]);
213
+ assert.equal(nextTurnOpen, replyLanded, "the block is stable across the turn boundary");
214
+ });
215
+
216
+
158
217
  // ---- issue #162: 注入边界花括号转义(v0.7.4 #40 修复被 v0.7.11 误删后恢复) ----
159
218
 
160
219
  test("issue #162: {{...}} in memory content is escaped at the injection boundary", () => {
@@ -0,0 +1,90 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // 7 天柱状纵轴的回归锁(2026-11)。原实现有两处真错,都在小用量库上暴露:
8
+ // ① 标签写死 (v/1e6).toFixed(0)+"M" —— 750K / 500K 都变 1M、250K 变 0M,
9
+ // 于是轴永远是「1M / 1M / 1M / 0M」。四条线只剩两个不同值时,图就失去刻度。
10
+ // ② 上限被 Math.max(1e6, …) 钉死 —— 日用量 7 万时柱子只有 7% 高,用量越大反而
11
+ // 越正常,正好反了。
12
+ // 第 3 条用例锁的就是 ① 的形状(标签去重后必须仍有 4 个),第 4 条锁 ②(上限不许
13
+ // 比峰值高出一倍半以上)。第 6 条是源级守卫:纯函数绿了、渲染绕过去也等于没修。
14
+
15
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
16
+ const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
17
+
18
+ /* ---------- 切片:纯函数块(同 panel-geometry 的先例) ---------- */
19
+ const begin = clientSource.indexOf("/* panel-chart-scale:begin");
20
+ const end = clientSource.indexOf("/* panel-chart-scale:end */");
21
+ assert.ok(begin > 0 && end > begin, "lib/client.js 里找不到 panel-chart-scale 标记块(锚点变了?)");
22
+ const block = clientSource.slice(begin, end);
23
+ const { chartScale, chartAxisLabel } = new Function(
24
+ `${block}\nreturn { chartScale, chartAxisLabel };`
25
+ )();
26
+
27
+ test("无数据不画轴:0 / 负数 / 非数值一律返回 null", () => {
28
+ for (const v of [0, -1, -1e6, NaN, null, undefined, "abc", "", Infinity]) {
29
+ assert.equal(chartScale(v), null, `chartScale(${String(v)}) 应为 null(调用方画空态)`);
30
+ }
31
+ });
32
+
33
+ test("按量级挑步长:70K / 900K / 2.4M / 3.9M / 12M 的轴都是四条不同的线", () => {
34
+ const cases = [
35
+ [70000, [80000, 60000, 40000, 20000]],
36
+ [900000, [1e6, 750000, 500000, 250000]],
37
+ [2400000, [4e6, 3e6, 2e6, 1e6]],
38
+ [3900000, [4e6, 3e6, 2e6, 1e6]],
39
+ [12000000, [2e7, 1.5e7, 1e7, 5e6]]
40
+ ];
41
+ for (const [dayMax, labels] of cases) {
42
+ const s = chartScale(dayMax);
43
+ assert.deepEqual(s.labels, labels, `dayMax=${dayMax} 的轴不对`);
44
+ assert.equal(s.max, labels[0], "max 必须等于最高那条线");
45
+ assert.equal(s.step * 4, s.max, "step × 4 必须等于 max");
46
+ }
47
+ });
48
+
49
+ test("四条轴标签互不相同(原 bug 的形状:去重后只剩 1M / 0M)", () => {
50
+ const samples = [1, 2, 3, 4, 5, 9, 10, 99, 999, 1000, 4321, 70000, 250000, 900000,
51
+ 1500000, 2400000, 3900000, 12000000, 48000000, 1e9];
52
+ for (const dayMax of samples) {
53
+ const s = chartScale(dayMax);
54
+ const texts = s.labels.map(chartAxisLabel);
55
+ assert.equal(new Set(texts).size, 4, `dayMax=${dayMax} 的轴标签有重复:${texts.join(" / ")}`);
56
+ }
57
+ });
58
+
59
+ test("上限够高又不浪费:max >= 峰值,且峰值 >= 1000 时不超过 2.5 倍", () => {
60
+ for (const dayMax of [1, 2, 3, 5, 999, 1000, 4321, 70000, 900000, 2400000, 3.9e6, 12e6, 1e9]) {
61
+ const s = chartScale(dayMax);
62
+ assert.ok(s.max >= dayMax, `dayMax=${dayMax} 时上限 ${s.max} 装不下柱子`);
63
+ if (dayMax >= 1000) {
64
+ assert.ok(s.max < dayMax * 2.5, `dayMax=${dayMax} 时上限 ${s.max} 浪费过半(又退回 1M 地板了?)`);
65
+ }
66
+ for (let i = 1; i < s.labels.length; i += 1) {
67
+ assert.ok(s.labels[i] < s.labels[i - 1], "轴标签必须自上而下递减");
68
+ }
69
+ }
70
+ });
71
+
72
+ test("轴标签按 K / M 分档且整百万不带小数", () => {
73
+ assert.equal(chartAxisLabel(1e6), "1M");
74
+ assert.equal(chartAxisLabel(3e6), "3M");
75
+ assert.equal(chartAxisLabel(1.5e6), "1.5M");
76
+ assert.equal(chartAxisLabel(750000), "750K");
77
+ assert.equal(chartAxisLabel(250000), "250K");
78
+ assert.equal(chartAxisLabel(80000), "80K");
79
+ assert.equal(chartAxisLabel(1000), "1K");
80
+ assert.equal(chartAxisLabel(500), "500");
81
+ assert.equal(chartAxisLabel(0), "0");
82
+ });
83
+
84
+ test("渲染真的用了这两个函数(纯函数绿了但渲染绕过去等于没修)", () => {
85
+ assert.ok(!/toFixed\(0\)\}M/.test(clientSource), "client.js 里还留着 (v/1e6).toFixed(0)M 的旧标签写法");
86
+ assert.ok(!/Math\.max\(1e6,/.test(clientSource), "client.js 里还留着 Math.max(1e6, …) 的上限地板");
87
+ assert.ok(/chartAxisLabel\(v\)/.test(clientSource), "网格标签没走 chartAxisLabel");
88
+ assert.ok(/d\.total \/ scale\.max/.test(clientSource), "柱高没走 scale.max");
89
+ assert.ok(/memory\.status\.token\.daysEmpty/.test(clientSource), "全零七天没有空态文案");
90
+ });
@@ -0,0 +1,245 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { readFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // 面板几何刻度层的回归锁(视觉系统 P0+P1a,docs/plans/2026-11-10-panel-visual-system.md)。
8
+ //
9
+ // 它咬五类漂移,每一类都真的发生过:
10
+ // ① 间距靠手写:padding 曾长出 107 种取值、只有 41.7% 落 4 网格、29% 是奇数像素。
11
+ // 现在 CSS 里每一处 px 都必须在「刻度集」或「已登记的存量」里,且存量**只许减
12
+ // 不许增**——新加一个 padding:11px 会红,把一处 11px 改成 8px 不会红(那是迁移)。
13
+ // ② 刻度定义散架:token 少了、值改了、挂的渲染根从五个变成四个,都会红。
14
+ // ③ 原语被绕过:GEOMETRY 之外的键(9 / 10 / "8px")必须当场抛错,宁可渲染炸掉,
15
+ // 也不要多出第 108 种间距——后者没人会注意,前者一进测试就红。
16
+ // ④ CSS 与 GEOMETRY 各说一套:两边的键必须一一对应,刻度加了 CSS 没加(或反过来)也红。
17
+ // ⑤ 迁移做一半:P1b 之后状态页的间距/圆角必须整页来自刻度,混回一个 padding:10px
18
+ // 就红——半迁移比不迁移更难读,因为看代码的人分不清哪个数是准的。
19
+ //
20
+ // 变异自证(PR 里要附):把 client.js 里任一处 `padding:0 16px` 改成 `padding:0 17px`
21
+ // → 本文件第 2 条用例红;把 GEOMETRY.space 里的 12 删掉但保留 --mneme-space-12
22
+ // → 第 4 条红;把 .mneme-paneliglyph 从 token 规则的选择器里删掉 → 第 3 条红;
23
+ // 把状态页任一处 var(--mneme-space-12) 改回 12px → 第 6 条红。
24
+
25
+ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
26
+ const clientSource = readFileSync(join(root, "lib/client.js"), "utf8");
27
+
28
+ /* ---------- 切片:与 test/contrast.test.js 同一对锚点 ---------- */
29
+ const cssStart = clientSource.indexOf("const css = [");
30
+ const cssEnd = clientSource.indexOf('].join("\\n");', cssStart);
31
+ assert.ok(cssStart > 0 && cssEnd > cssStart, "lib/client.js 里找不到 CSS 数组(切片锚点变了?)");
32
+ const cssBlock = clientSource.slice(cssStart, cssEnd);
33
+
34
+ // 注释里的 px 是散文(「隐掉 14px 的 tab 图标」),不是声明;只量真声明。
35
+ const declaredCss = cssBlock.split("\n").filter((l) => !/^\s*\/\//.test(l)).join("\n");
36
+ const joinedCss = [...cssBlock.matchAll(/"((?:[^"\\]|\\.)*)"/g)].map((m) => m[1].replace(/\\"/g, '"')).join("");
37
+
38
+ /* ---------- 刻度集:与 client.js 的 GEOMETRY 表成对出现 ---------- */
39
+ const EXPECTED = {
40
+ space: { 0: "0", 2: "2px", 4: "4px", 6: "6px", 8: "8px", 10: "10px", 12: "12px", 16: "16px", 20: "20px", 24: "24px", 40: "40px" },
41
+ radius: { 4: "4px", 8: "8px", 12: "12px", pill: "999px" },
42
+ type: { 12: "12px", 13: "13px", 15: "15px" },
43
+ weight: { 400: "400", 600: "600" }
44
+ };
45
+
46
+ test("几何刻度定义完整且取值未漂移", () => {
47
+ const found = {};
48
+ for (const m of joinedCss.matchAll(/--mneme-(space|radius|type|weight)-([a-z0-9]+)\s*:\s*([^;}]+)/g)) {
49
+ (found[m[1]] ||= {})[m[2]] = m[3].trim();
50
+ }
51
+ for (const [group, table] of Object.entries(EXPECTED)) {
52
+ assert.deepEqual(found[group] || {}, table, `--mneme-${group}-* 刻度集与 GEOMETRY 不一致`);
53
+ }
54
+ // 反向:不许有表外的 --mneme-<group>-*。加了新档必须同时进这里和 GEOMETRY。
55
+ for (const group of Object.keys(EXPECTED)) {
56
+ const extra = Object.keys(found[group] || {}).filter((k) => !(k in EXPECTED[group]));
57
+ assert.deepEqual(extra, [], `--mneme-${group}-* 出现表外档位:${extra.join(",")}`);
58
+ }
59
+ });
60
+
61
+ test("几何刻度挂满五个渲染根(少挂一个就等于没定义)", () => {
62
+ const m = /([^{}]*)\{[^{}]*--mneme-space-8\s*:/.exec(joinedCss);
63
+ assert.ok(m, "找不到定义几何刻度的规则");
64
+ const sel = m[1].split("}").pop().trim();
65
+ assert.equal(
66
+ sel,
67
+ ".mneme-x,.mneme-overlay,.mneme-triggerwrap,.mneme-topentry,.mneme-paneliglyph",
68
+ "几何刻度必须和语义色层挂同一组五个根:overlay / 侧栏插槽 / 顶部入口 / 宿主 panelGlyph 都不在 .mneme-x 子树里"
69
+ );
70
+ });
71
+
72
+ /* ---------- 存量棘轮:px 只许减少,不许新增 ---------- */
73
+ // 逐值计数,冻结在「本 PR 合进来的那棵树」上:ef8f9ce 之后 main 又合了 #642(情感归属可见),
74
+ // 它自己在 client.js 里带进 21 处 px(1px +3 / 2px +2 / 3px +1 / 6px +5 / 7px +1 / 8px +2 /
75
+ // 11px +4 / 14px +1 / 16px +2),所以下表 = ef8f9ce 基线 − 本分支的迁移 + #642 的增量。
76
+ // 本分支相对 main 净减 87 处(减 89、加 2,那 2 处是 15px 字阶与 999px pill 半径这两个
77
+ // 刻度定义本身,不是手写间距)。迁移时**把数字往下改**,不要为了让测试变绿去加值——
78
+ // 加值等于把漂移写进法律;下表这次往上动,是因为 main 先涨了。
79
+ // 2026-11-11 又一次上调(#647 与视觉系统合并时):#618 的地志 wiki 页在 main 上
80
+ // 带进了新 px(1px +3 / 3px +1 / 5px +1 / 6px +7 / 10px +3 / 11px +3 / 12px +3 /
81
+ // 13px +1 / 14px +2 / 16px +4 / 17px +1 / 18px +1 / 11.5px +2 / 10.5px +1),基线
82
+ // 实测于它合入之前。wiki 页的刻度化是地志车道的跟进任务,本棘轮从当前树重新
83
+ // 冻结——下一只动基线的手必须先证明自己在做迁移。
84
+ const BASELINE = {
85
+ 1: 137, 1.5: 8, 2: 72, 2.5: 1, 3: 28, 4: 46,
86
+ 5: 22, 6: 95, 7: 21, 8: 160, 9: 18, 9.5: 4,
87
+ 10: 118, 10.5: 5, 11: 58, 11.5: 11, 12: 144, 12.5: 15,
88
+ 13: 54, 13.5: 6, 14: 57, 15: 12, 16: 74, 17: 28,
89
+ 18: 46, 19: 14, 20: 20, 21: 4, 22: 14, 23: 1,
90
+ 24: 21, 25: 2, 26: 12, 27: 1, 28: 10, 30: 7,
91
+ 32: 11, 34: 1, 36: 5, 40: 5, 42: 1, 44: 2,
92
+ 46: 3, 48: 3, 52: 2, 64: 2, 76: 1, 84: 2,
93
+ 88: 1, 96: 4, 108: 1, 110: 1, 120: 1, 132: 1,
94
+ 148: 2, 150: 2, 180: 6, 190: 1, 200: 4, 216: 1,
95
+ 220: 4, 224: 1, 230: 1, 232: 1, 240: 3, 252: 1,
96
+ 260: 1, 264: 1, 280: 1, 300: 4, 320: 1, 340: 2,
97
+ 360: 1, 432: 1, 452: 2, 480: 3, 500: 1, 560: 3,
98
+ 620: 1, 640: 3, 680: 1, 683: 1, 720: 3, 860: 1,
99
+ 920: 1, 960: 1, 999: 1, 1000: 7, 1240: 1
100
+ };
101
+
102
+ // 合法例外:不登记就等于「随便写」。每条都要说清为什么它不该进刻度。
103
+ const WHITELIST = {
104
+ 1: "1px 描边/分隔线(发丝线,缩放后仍是线而不是块)",
105
+ 2: "2px 焦点环与 tab 下划线(视觉系统 half-step,非布局间距)"
106
+ };
107
+
108
+ test("CSS 里的 px 只能来自刻度或存量,存量只许减不许增", () => {
109
+ const current = {};
110
+ for (const m of declaredCss.matchAll(/(?<![\w.-])(\d+(?:\.\d+)?)px/g)) {
111
+ const v = Number(m[1]);
112
+ current[v] = (current[v] || 0) + 1;
113
+ }
114
+ const grew = [];
115
+ const intruders = [];
116
+ for (const [v, count] of Object.entries(current)) {
117
+ const key = Number(v);
118
+ if (key in BASELINE) {
119
+ if (count > BASELINE[key]) grew.push(`${v}px: ${BASELINE[key]} → ${count}`);
120
+ } else if (!(key in WHITELIST)) {
121
+ intruders.push(`${v}px × ${count}`);
122
+ }
123
+ }
124
+ assert.deepEqual(intruders, [], "出现刻度外的 px 取值(先问它该不该是一个新档):" + intruders.join(", "));
125
+ assert.deepEqual(grew, [], "存量 px 变多了(迁移应让它变少;确实要加请先想清楚是不是该用刻度):" + grew.join(", "));
126
+ // 白名单条目失效要报红:值在代码里消失了说明刻度或写法变了,清单没跟着走就是僵尸条目。
127
+ for (const [v, why] of Object.entries(WHITELIST)) {
128
+ assert.ok(Number(v) in current, `白名单里的 ${v}px 已经不在 CSS 里了(${why})——清单该删该改?`);
129
+ }
130
+ });
131
+
132
+ /* ---------- 迁移进度锁:状态页的几何全部来自刻度(P1b) ---------- */
133
+ // 状态视图 = 状态卡网格 + Token 三屏 + 冲突队列 + 工作台。这一片此前是「最像手写
134
+ // 排版」的一片:padding 有 16/14/10/8 四档混用,圆角有 12/10/8/3 四种,分组间距
135
+ // 26px、页尾留白 40px 都不在任何刻度上。现在全部走 token,并且这条锁不允许回退。
136
+ //
137
+ // 两个例外,都写在这里而不是放任:
138
+ // · 1px —— 光学微调(.mneme-conflict-mark 的左右 padding 这么宽),
139
+ // 它的作用是「贴着」而不是「隔着一段」,进刻度会让文字挤在一起;
140
+ // · 断点档(@container / @media)整体跳过:那一档是「压到正好不溢出」的贴合值
141
+ // (14px / 32px / 30px),不是节奏;把它收进刻度等于每次调断点都要改刻度表。
142
+ const STATUS_NS = /^\.mneme-(status|stat|tokenflow|kpiband|donut|sink|legacy|wb|conflict|pending|heatbar)/;
143
+ const GEOM_PROP = /^(padding|margin|gap|row-gap|column-gap)(-(top|right|bottom|left))?$/;
144
+
145
+ test("状态页的间距与圆角全部走刻度(P1b 不回退)", () => {
146
+ const violations = [];
147
+ let coveredRules = 0;
148
+ let coveredDecls = 0;
149
+ for (const raw of declaredCss.split("\n")) {
150
+ const line = raw.trim();
151
+ if (line.startsWith("@")) continue;
152
+ const m = /^"([^"]*)\{(.*)\}"?,?$/.exec(line);
153
+ if (!m) continue;
154
+ const selector = m[1];
155
+ if (!selector.split(",").some((s) => STATUS_NS.test(s.trim()))) continue;
156
+ coveredRules += 1;
157
+ for (const decl of m[2].split(";")) {
158
+ const at = decl.indexOf(":");
159
+ if (at < 0) continue;
160
+ const prop = decl.slice(0, at).trim();
161
+ const value = decl.slice(at + 1).trim();
162
+ const isSpacing = GEOM_PROP.test(prop);
163
+ const isRadius = prop === "border-radius";
164
+ if (!isSpacing && !isRadius) continue;
165
+ coveredDecls += 1;
166
+ // 先把 token 抠出来查「档位存不存在、组对不对」:var(--mneme-space-13) 这种
167
+ // 未定义的键不会报错、只是渲染成空值,光看前缀是查不出来的。
168
+ const keys = [...value.matchAll(/var\(--mneme-(space|radius)-([a-z0-9]+)\)/g)];
169
+ for (const k of keys) {
170
+ const table = EXPECTED[k[1]];
171
+ if (!(k[2] in table)) {
172
+ violations.push(selector + " { " + prop + ": " + value + " } ← " + k[1] + " 没有档位 " + k[2]);
173
+ }
174
+ }
175
+ const rest = value.replace(/var\(--mneme-(?:space|radius)-[a-z0-9]+\)/g, " ").split(/\s+/).filter(Boolean);
176
+ const leftoversOk = isSpacing
177
+ ? rest.every((x) => x === "0" || x === "auto" || x === "1px")
178
+ : rest.every((x) => x === "0" || x === "50%");
179
+ const groupOk = isSpacing ? keys.every((k) => k[1] === "space") : keys.every((k) => k[1] === "radius");
180
+ // 注意:不能要求「必须有 token」——auto / 1px / 50% 是刻意的例外,
181
+ // 本身就已经是合法值(leftoversOk 会放它们过),要求 token 只会把它们误判成违规。
182
+ if (!leftoversOk || !groupOk) {
183
+ violations.push(selector + " { " + prop + ": " + value + " }");
184
+ }
185
+ }
186
+ }
187
+ assert.deepEqual(violations, [], "状态页出现刻度外的间距/圆角:" + violations.join(" ; "));
188
+ // 反向:不许因为「一个状态类都没匹配上」而空转通过(改类名、改切片锚点都会走到这里)。
189
+ assert.ok(coveredRules >= 80, "扫到的状态页规则只有 " + coveredRules + " 条(预期 ≥80),锁空转了?");
190
+ assert.ok(coveredDecls >= 60, "扫到的状态页几何声明只有 " + coveredDecls + " 条(预期 ≥60),锁空转了?");
191
+ });
192
+
193
+ /* ---------- 原语层:抽出来真跑,不靠读源码猜 ---------- */
194
+ const gStart = clientSource.indexOf(" const GEOMETRY = {");
195
+ const gEnd = clientSource.indexOf("/* panel-geometry:end */", gStart);
196
+ assert.ok(gStart > 0 && gEnd > gStart, "找不到 panel-geometry 标记块(原语层被删或标记丢了)");
197
+ const geometrySource = clientSource.slice(gStart, gEnd);
198
+
199
+ const makeApi = () =>
200
+ new Function(
201
+ "const h = arguments[0];\n" +
202
+ geometrySource +
203
+ "\nreturn { GEOMETRY, space, radius, fontOf, weightOf, Stack, Inline, Card, Section, Grid };"
204
+ )((type, props, ...kids) => ({ type, props, kids }));
205
+
206
+ test("原语层只收刻度键,表外值当场抛错(这是这条锁存在的理由)", () => {
207
+ const api = makeApi();
208
+ assert.equal(api.space(8), "var(--mneme-space-8)");
209
+ assert.equal(api.space(0), "var(--mneme-space-0)");
210
+ assert.equal(api.space(24), "var(--mneme-space-24)");
211
+ assert.equal(api.radius("pill"), "var(--mneme-radius-pill)");
212
+ assert.equal(api.fontOf(13), "var(--mneme-type-13)");
213
+ assert.equal(api.weightOf(600), "var(--mneme-weight-600)");
214
+ for (const bad of [9, 11, 14, 25, "8px", "8 ", null, undefined]) {
215
+ assert.throws(() => api.space(bad), /只收刻度/, `space(${JSON.stringify(bad)}) 必须抛错`);
216
+ }
217
+ assert.throws(() => api.radius(6), /只收刻度/, "radius 不共享 space 的档位");
218
+ assert.throws(() => api.space("16"), /只收刻度/, "字符串键同样要查表(16 不是档位)");
219
+ });
220
+
221
+ test("原语真的把刻度写进 style,且默认值都在刻度内", () => {
222
+ const api = makeApi();
223
+ const stack = api.Stack({ gap: 12, pad: 8, align: "center", children: "x" });
224
+ assert.equal(stack.props.style.gap, "var(--mneme-space-12)");
225
+ assert.equal(stack.props.style.padding, "var(--mneme-space-8)");
226
+ assert.equal(stack.props.style.flexDirection, "column");
227
+ assert.throws(() => api.Stack({ gap: 9 }), /只收刻度/);
228
+
229
+ assert.equal(api.Inline({}).props.style.gap, "var(--mneme-space-6)");
230
+ assert.equal(api.Inline({}).props.style.flexDirection, "row");
231
+ assert.equal(api.Card({}).props.style.padding, "var(--mneme-space-12)");
232
+ assert.equal(api.Card({ dense: true }).props.style.padding, "var(--mneme-space-8)");
233
+ assert.equal(api.Card({}).props.style.borderRadius, "var(--mneme-radius-8)");
234
+ assert.match(String(api.Grid({}).props.style.gridTemplateColumns), /^repeat\(auto-fill,minmax\(220px,1fr\)\)$/);
235
+ assert.equal(api.Grid({ gap: 16 }).props.style.gap, "var(--mneme-space-16)");
236
+ assert.throws(() => api.Grid({ gap: 11 }), /只收刻度/);
237
+ assert.equal(api.Section({}).props.style.gap, "var(--mneme-space-8)");
238
+ });
239
+
240
+ test("原语层内部不许出现裸 px 字面量(1px 发丝线与栅格下限除外)", () => {
241
+ // 两个例外写在这里而不是放任:1px 是发丝线(缩放后仍是线),栅格 min 是
242
+ // 「内容多宽才排下一列」的阈值,不是间距——它进刻度反而会让栅格跟着间距漂。
243
+ const literal = (geometrySource.match(/(?<![\w.-])\d+px/g) || []).filter((v) => v !== "1px");
244
+ assert.deepEqual(literal, [], "原语层写了 px 字面量,绕过了刻度:" + literal.join(", "));
245
+ });
@@ -119,6 +119,14 @@ test("重复跑是幂等的:已有目标的占位行就不再插第二行", ()
119
119
  test("SECURITY.md 支持版本表:同 minor 通过,跨 minor 如实报出中英两处", () => {
120
120
  const sameMinor = checkSupportTable(securityDoc, version);
121
121
  assert.equal(sameMinor.ok, true, `真实 SECURITY.md 应与 package.json ${version} 一致:${sameMinor.problems.join(";")}`);
122
+ if (version.includes("-")) {
123
+ // 预发布版不占支持表的格:beta 不是受支持的稳定版本,挪过去等于对外宣称它已活跃维护。
124
+ // 显式断言 skipped,而不是让它悄悄走 ok 分支——否则「不检查」和「检查通过」长得一样。
125
+ assert.equal(sameMinor.skipped, "prerelease",
126
+ `预发布版 ${version} 应显式标 skipped=prerelease,实际 ${sameMinor.skipped}`);
127
+ } else {
128
+ assert.equal(sameMinor.skipped, null, `稳定版 ${version} 不该被当成预发布版跳过`);
129
+ }
122
130
 
123
131
  const nextMinor = `${version.split(".")[0]}.${Number(version.split(".")[1]) + 1}.0`;
124
132
  const crossing = checkSupportTable(securityDoc, nextMinor);
@@ -128,6 +136,18 @@ test("SECURITY.md 支持版本表:同 minor 通过,跨 minor 如实报出中
128
136
  "每条问题都要写出期望值,否则人不知道改成什么:" + crossing.problems.join(";"));
129
137
  });
130
138
 
139
+ test("预发布版不把支持表首行挪到自己头上(beta 不是受支持的稳定版)", () => {
140
+ const [major, minor] = "0.14.0-beta.1".split(".");
141
+ const betaRow = `${major}.${minor}.x`;
142
+ const r = checkSupportTable(securityDoc, "0.14.0-beta.1");
143
+ assert.equal(r.ok, true);
144
+ assert.equal(r.expected, betaRow, "expected 仍按 major.minor 算,只是不拿来要求表");
145
+ const body = securityDoc.slice(securityDoc.indexOf("## Supported Versions"));
146
+ const firstRow = body.split(/\r?\n/).filter((l) => /^\|\s*[\d<]/.test(l))[0].split("|")[1].trim();
147
+ assert.notEqual(firstRow, betaRow,
148
+ `支持表首行不该是 ${betaRow}——预发布版占格等于宣称它已在活跃维护`);
149
+ });
150
+
131
151
  test("SECURITY.md 支持版本表:节缺失或表格形状变了也报错(不能静默通过)", () => {
132
152
  assert.equal(checkSupportTable("# Security\n\n## 别的节\n", "0.9.0").ok, false);
133
153
  const noRows = "## Supported Versions\r\n\r\n没有表格\r\n\r\n## 支持版本\r\n\r\n没有表格\r\n";