@modusensus/dsh-mneme 0.12.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 (182) hide show
  1. package/README.md +51 -20
  2. package/bin/dsh-mneme-choro.mjs +9 -1
  3. package/docs/CONFIGURATION.md +47 -6
  4. package/docs/DIRECTORIES.md +114 -0
  5. package/docs/DISTILL.md +64 -0
  6. package/docs/DSH-GO.md +97 -0
  7. package/docs/ENTITIES.md +5 -3
  8. package/docs/FORGET.md +77 -0
  9. package/docs/KB.md +110 -0
  10. package/docs/SLEEP.md +28 -3
  11. package/docs/STORAGE.md +23 -0
  12. package/lib/api-standalone.js +4 -1
  13. package/lib/api.js +788 -30
  14. package/lib/attachment-text.js +122 -0
  15. package/lib/choro-command.js +9 -2
  16. package/lib/client.js +4627 -1409
  17. package/lib/config.js +166 -1
  18. package/lib/continuity.js +7 -6
  19. package/lib/diary/auto.js +190 -0
  20. package/lib/diary/compose.js +440 -0
  21. package/lib/diary/dir.js +59 -0
  22. package/lib/diary/export.js +97 -0
  23. package/lib/diary/index.js +26 -0
  24. package/lib/diary/library.js +189 -0
  25. package/lib/diary/qq-inbox.js +142 -0
  26. package/lib/diary/rollover.js +61 -0
  27. package/lib/diary/session-provider.js +122 -0
  28. package/lib/dream/decisions.js +95 -6
  29. package/lib/dream/sleep.js +760 -19
  30. package/lib/dream/triage.js +139 -0
  31. package/lib/dream.js +168 -21
  32. package/lib/dsh-go/host.js +162 -0
  33. package/lib/dsh-go/panel.js +2091 -0
  34. package/lib/dsh-go/text.js +342 -0
  35. package/lib/dsh-go/views/common.js +68 -0
  36. package/lib/dsh-go/views/entity.js +48 -0
  37. package/lib/dsh-go/views/memory.js +198 -0
  38. package/lib/dsh-go/views/root.js +30 -0
  39. package/lib/dsh-go/views/settings.js +398 -0
  40. package/lib/dsh-go/views/status.js +51 -0
  41. package/lib/embedding.js +42 -8
  42. package/lib/entities/extractor.js +36 -7
  43. package/lib/hot-memory.js +22 -11
  44. package/lib/index.js +123 -3
  45. package/lib/inject.js +203 -20
  46. package/lib/kb/index.js +309 -0
  47. package/lib/kb/routes.js +220 -0
  48. package/lib/kb/tools.js +148 -0
  49. package/lib/lang.js +98 -4
  50. package/lib/peak-hours.js +57 -2
  51. package/lib/semantic.js +347 -60
  52. package/lib/service.js +268 -69
  53. package/lib/settings.js +128 -15
  54. package/lib/store.js +585 -26
  55. package/lib/summarize.js +38 -11
  56. package/lib/tools.js +35 -4
  57. package/lib/user-settings.js +128 -32
  58. package/lib/vector-index.js +76 -6
  59. package/lib/wiki/claims.js +267 -0
  60. package/lib/wiki/cli-hint.js +90 -0
  61. package/lib/wiki/queries.js +282 -2
  62. package/lib/wiki/render.js +74 -0
  63. package/lib/wiki/t0.js +43 -6
  64. package/lib/wiki/t1-jobs.js +232 -0
  65. package/lib/wiki/t1.js +145 -32
  66. package/package.json +39 -1
  67. package/scripts/release-docs.mjs +7 -1
  68. package/scripts/release-notes.mjs +4 -1
  69. package/src/api-standalone.js +4 -1
  70. package/src/api.js +788 -30
  71. package/src/attachment-text.js +122 -0
  72. package/src/choro-command.js +9 -2
  73. package/src/config.js +166 -1
  74. package/src/continuity.js +7 -6
  75. package/src/diary/auto.js +190 -0
  76. package/src/diary/compose.js +440 -0
  77. package/src/diary/dir.js +59 -0
  78. package/src/diary/export.js +97 -0
  79. package/src/diary/index.js +26 -0
  80. package/src/diary/library.js +189 -0
  81. package/src/diary/qq-inbox.js +142 -0
  82. package/src/diary/rollover.js +61 -0
  83. package/src/diary/session-provider.js +122 -0
  84. package/src/dream/decisions.js +95 -6
  85. package/src/dream/sleep.js +760 -19
  86. package/src/dream/triage.js +139 -0
  87. package/src/dream.js +168 -21
  88. package/src/dsh-go/host.js +162 -0
  89. package/src/dsh-go/panel.js +2091 -0
  90. package/src/dsh-go/text.js +342 -0
  91. package/src/dsh-go/views/common.js +68 -0
  92. package/src/dsh-go/views/entity.js +48 -0
  93. package/src/dsh-go/views/memory.js +198 -0
  94. package/src/dsh-go/views/root.js +30 -0
  95. package/src/dsh-go/views/settings.js +398 -0
  96. package/src/dsh-go/views/status.js +51 -0
  97. package/src/embedding.js +42 -8
  98. package/src/entities/extractor.js +36 -7
  99. package/src/hot-memory.js +22 -11
  100. package/src/index.js +123 -3
  101. package/src/inject.js +203 -20
  102. package/src/kb/index.js +309 -0
  103. package/src/kb/routes.js +220 -0
  104. package/src/kb/tools.js +148 -0
  105. package/src/lang.js +98 -4
  106. package/src/peak-hours.js +57 -2
  107. package/src/semantic.js +347 -60
  108. package/src/service.js +268 -69
  109. package/src/settings.js +128 -15
  110. package/src/store.js +585 -26
  111. package/src/summarize.js +38 -11
  112. package/src/tools.js +35 -4
  113. package/src/user-settings.js +128 -32
  114. package/src/vector-index.js +76 -6
  115. package/src/wiki/claims.js +267 -0
  116. package/src/wiki/cli-hint.js +90 -0
  117. package/src/wiki/queries.js +282 -2
  118. package/src/wiki/render.js +74 -0
  119. package/src/wiki/t0.js +43 -6
  120. package/src/wiki/t1-jobs.js +232 -0
  121. package/src/wiki/t1.js +145 -32
  122. package/test/api-routes-gaps.test.js +63 -0
  123. package/test/api.test.js +385 -4
  124. package/test/attachment-text.test.js +140 -0
  125. package/test/bm25-corpus-cache.test.js +145 -0
  126. package/test/capacity-warning.test.js +262 -0
  127. package/test/client.test.js +1051 -185
  128. package/test/conflict-freeze.test.js +45 -1
  129. package/test/conflict-ledger-enforce.test.js +452 -0
  130. package/test/conflict-ledger.test.js +242 -0
  131. package/test/continuity.test.js +8 -1
  132. package/test/contrast.test.js +124 -12
  133. package/test/diary-library.test.js +187 -0
  134. package/test/diary.test.js +576 -0
  135. package/test/dream-baseline-clamp.test.js +174 -0
  136. package/test/dream-maxtokens-fallback.test.js +3 -3
  137. package/test/dream.test.js +62 -1
  138. package/test/dsh-go-host.test.js +134 -0
  139. package/test/dsh-go-panel-actions.test.js +854 -0
  140. package/test/dsh-go-panel.test.js +251 -0
  141. package/test/embedding-ready.test.js +33 -3
  142. package/test/entities.test.js +73 -0
  143. package/test/forget-corrections.test.js +70 -0
  144. package/test/graph-api.test.js +124 -0
  145. package/test/hot-memory.test.js +48 -0
  146. package/test/image-memory.test.js +171 -0
  147. package/test/inject-parent-gate.test.js +6 -3
  148. package/test/inject.test.js +59 -0
  149. package/test/issue-617-step1-query-and-fusion.test.js +361 -0
  150. package/test/kb-api-wiring.test.js +128 -0
  151. package/test/kb-collections.test.js +272 -0
  152. package/test/kb-routes.test.js +379 -0
  153. package/test/mint-source.test.js +26 -0
  154. package/test/panel-chart-scale.test.js +90 -0
  155. package/test/panel-tokens.test.js +245 -0
  156. package/test/peak-hours-observability.test.js +70 -0
  157. package/test/persona-api.test.js +3 -3
  158. package/test/persona-by-session.test.js +568 -0
  159. package/test/persona-settings.test.js +7 -7
  160. package/test/reflection-update-gate.test.js +129 -0
  161. package/test/reindex-backfill.test.js +334 -0
  162. package/test/reindex-scheduler.test.js +369 -0
  163. package/test/release-docs.test.js +20 -0
  164. package/test/release-notes.test.js +25 -0
  165. package/test/scope-candidates.test.js +249 -0
  166. package/test/security-doc.test.js +10 -0
  167. package/test/sleep-action-set.test.js +14 -0
  168. package/test/sleep-heat.test.js +49 -0
  169. package/test/sleep.test.js +635 -0
  170. package/test/standalone-api.test.js +24 -0
  171. package/test/store.test.js +20 -0
  172. package/test/summarize.test.js +26 -0
  173. package/test/user-settings-segments.test.js +528 -0
  174. package/test/vector-index.test.js +120 -6
  175. package/test/wiki-api.test.js +463 -1
  176. package/test/wiki-claims.test.js +194 -0
  177. package/test/wiki-cli-hint.test.js +118 -0
  178. package/test/wiki-provenance.test.js +202 -0
  179. package/test/wiki-queries.test.js +128 -0
  180. package/test/wiki-t0.test.js +77 -1
  181. package/test/wiki-t1-jobs.test.js +125 -0
  182. package/test/wiki-t1.test.js +226 -1
@@ -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
+ });
@@ -0,0 +1,70 @@
1
+ // Issue #622 可观测性回归:错峰时段串解析失败不许静默。
2
+ //
3
+ // parsePeakSpec 的失败语义(整串视为未配置)是刻意的契约——排程是省钱手段,绝不
4
+ // 该因为写错格式把蒸馏/巩固停掉。但「不救错」不等于「不吭声」:#424 的排查只能靠
5
+ // 「高峰期还在巩固」倒推,正是静默的全貌。本文件锁三件事:
6
+ // ① invalidPeakSpecs 只报「非空且解析失败」——空串/未配置/合法串/全角归一救得了
7
+ // 的写法一律不报(验收第 2 条:不骚扰没配错峰的用户;#621 已落归一,两处共存);
8
+ // ② index.js 的启动告警真的接在 cfg 装配之后(接线锁,仿 failure-retention 的
9
+ // 写法):告警读的是装配后的 cfg——面板改的 flatFlags 合并进来的值也算数;
10
+ // ③ /features 答复的 invalidPeakHours 与 helper 同源(api.test.js 里另有往返)。
11
+ import test from "node:test";
12
+ import assert from "node:assert/strict";
13
+ import { readFileSync } from "node:fs";
14
+ import { fileURLToPath } from "node:url";
15
+ import path from "node:path";
16
+ import { parsePeakSpec, invalidPeakSpecs, PEAK_SPEC_KEYS } from "../src/peak-hours.js";
17
+
18
+ const here = path.dirname(fileURLToPath(import.meta.url));
19
+ const indexSrc = readFileSync(path.join(here, "..", "src", "index.js"), "utf8");
20
+
21
+ test("invalidPeakSpecs: 只报非空且解析失败的键;空串/未配置/合法串不报", () => {
22
+ // 未配置(undefined)与空串:合法的「未配置」,不告警
23
+ assert.deepEqual(invalidPeakSpecs({}), {});
24
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "" }), {});
25
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: " " }), {});
26
+ assert.deepEqual(invalidPeakSpecs({ dreamPeakHours: undefined }), {});
27
+ // 合法串:不报
28
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "09:00-18:00" }), {});
29
+ assert.deepEqual(invalidPeakSpecs({ dreamPeakHours: "mon-fri 08:00-12:00,14:00-18:00" }), {});
30
+ // 全角等价写法:本批原基于未含 #621 归一的主线,当时 parsePeakSpec 解析失败、
31
+ // 「报」是真话。**#621 已并入 main**(全角逗号/冒号/数字归一),解析现在成功,
32
+ // 本断言随之翻转为不报——这正是 helper「只转述解析器、不自带第二套对错标准」
33
+ // 的意义:判据翻转由解析器驱动,本文件不需要改任何逻辑。
34
+ // ⚠️ 原注释里「两批共存无需联动改动」这句是错的:判据确实会翻,只是翻的位置
35
+ // 在这里而不在 src/。合并 #621 后必须同步翻这一行,否则就是假红。
36
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "09:00-18:00" }), {});
37
+ // 非法串:报,且带原串(告警要对得上配置文件里那一行)
38
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: "24:00-08:00" }), { summarizePeakHours: "24:00-08:00" });
39
+ assert.deepEqual(invalidPeakSpecs({ dreamPeakHours: "09.00-18.00" }), { dreamPeakHours: "09.00-18.00" });
40
+ // 两个键互相独立,各查各的("ok" 没有冒号、没有时段段——解析器视为非法,报得对)
41
+ const both = invalidPeakSpecs({ summarizePeakHours: "09:00-18:00", dreamPeakHours: "24:00-01:00" });
42
+ assert.deepEqual(both, { dreamPeakHours: "24:00-01:00" });
43
+ // 非对象(裸调用方传 undefined 之类)不抛、返回空
44
+ assert.deepEqual(invalidPeakSpecs(undefined), {});
45
+ assert.deepEqual(invalidPeakSpecs(null), {});
46
+ // 非字符串值(垃圾输入)不抛、不报——typeof 守卫
47
+ assert.deepEqual(invalidPeakSpecs({ summarizePeakHours: 42 }), {});
48
+ });
49
+
50
+ test("PEAK_SPEC_KEYS 恰好覆盖两个时段键,parsePeakSpec 对它们的失败语义不变", () => {
51
+ // 键名单独成表:告警点与提示点都要认同一份,谁加键谁改表(不各写数组各自漂移)
52
+ assert.deepEqual(PEAK_SPEC_KEYS, ["summarizePeakHours", "dreamPeakHours"]);
53
+ // 本 issue 不动解析语义:非法 → null、空 → null、合法 → 结构(#239 契约原样)
54
+ assert.equal(parsePeakSpec("24:00-08:00"), null);
55
+ assert.equal(parsePeakSpec(""), null);
56
+ assert.ok(parsePeakSpec("09:00-18:00"));
57
+ });
58
+
59
+ test("接线锁:index.js 的启动告警读装配后的 cfg,且在 sessionGate 之前", () => {
60
+ const cfgAt = indexSrc.indexOf("const cfg = { ...applyLightModePreset({ ...rawCfg, lightMode }), ...flatFlags };");
61
+ const callAt = indexSrc.indexOf("invalidPeakSpecs(cfg)");
62
+ const gateAt = indexSrc.indexOf("const sessionGate = createSessionGate(cfg);");
63
+ assert.ok(cfgAt > 0, "装配行必须在");
64
+ assert.ok(callAt > cfgAt, "告警必须排在 cfg 装配之后——读 rawCfg 会漏掉面板改的 flatFlags");
65
+ assert.ok(gateAt > callAt, "告警在 sessionGate 之前(纯 log,不参与装配顺序的实质,锁在这里只为防它被挪进更早、读不到合并值的分支)");
66
+ // 只 log:告警分支里不许出现改写 cfg 的语句(行为零变化是本 issue 的前提)
67
+ const block = indexSrc.slice(callAt, gateAt);
68
+ assert.ok(!/cfg\s*=[^=]/.test(block), "告警分支不得改写 cfg");
69
+ assert.ok(block.includes("logger?.warn?.("), "失败可见性走 logger.warn");
70
+ });
@@ -75,7 +75,7 @@ test("internal PUT /persona without token is rejected and store untouched", asyn
75
75
  const res = new FakeRes();
76
76
  await route.handler(req("/api/dsh-mneme/persona", "PUT", { persona: { name: "evil" } }), res);
77
77
  assert.equal(res.statusCode, 401);
78
- assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {} });
78
+ assert.deepEqual(settings.getPersona(), { default: {}, byAgent: {}, bySession: {} });
79
79
  store.close();
80
80
  });
81
81
 
@@ -140,7 +140,7 @@ test("standalone GET/PUT /persona behind Bearer gate round-trips", async () => {
140
140
  try {
141
141
  const get1 = await fetch(`${t.base}/persona`, { headers: t.auth });
142
142
  assert.equal(get1.status, 200);
143
- assert.deepEqual((await get1.json()).persona, { default: {}, byAgent: {} });
143
+ assert.deepEqual((await get1.json()).persona, { default: {}, byAgent: {}, bySession: {} });
144
144
  const put = await fetch(`${t.base}/persona`, {
145
145
  method: "PUT",
146
146
  headers: { ...t.auth, "content-type": "application/json" },
@@ -165,7 +165,7 @@ test("standalone /persona without Bearer is 401", async () => {
165
165
  try {
166
166
  const res = await fetch(`${t.base}/persona`, { method: "PUT", body: "{}" });
167
167
  assert.equal(res.status, 401);
168
- assert.deepEqual(t.settings.getPersona(), { default: {}, byAgent: {} });
168
+ assert.deepEqual(t.settings.getPersona(), { default: {}, byAgent: {}, bySession: {} });
169
169
  } finally {
170
170
  t.close();
171
171
  }