@modusensus/dsh-mneme 0.9.0 → 0.9.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 (88) hide show
  1. package/README.md +78 -16
  2. package/bin/cli.mjs +8 -2
  3. package/docs/CONFIGURATION.md +25 -0
  4. package/docs/SEMANTIC.md +3 -3
  5. package/lib/api-standalone.js +78 -12
  6. package/lib/api.js +282 -57
  7. package/lib/candidates.js +454 -454
  8. package/lib/client.js +598 -248
  9. package/lib/config.js +33 -2
  10. package/lib/embedding.js +116 -15
  11. package/lib/index.js +32 -0
  12. package/lib/inject.js +34 -3
  13. package/lib/lang.js +124 -0
  14. package/lib/pipeline-activities.js +178 -0
  15. package/lib/pipeline-attribution.js +165 -0
  16. package/lib/pipeline-command.js +279 -0
  17. package/lib/pipeline-confirm.js +142 -0
  18. package/lib/pipeline-exec.js +174 -0
  19. package/lib/pipeline-nudge.js +1281 -0
  20. package/lib/pipeline-observations.js +181 -0
  21. package/lib/pipeline-observe.js +133 -0
  22. package/lib/pipeline-runner.js +346 -0
  23. package/lib/pipeline-runs.js +442 -0
  24. package/lib/pipeline.js +128 -7
  25. package/lib/semantic.js +66 -10
  26. package/lib/settings.js +132 -1
  27. package/lib/store.js +77 -3
  28. package/lib/summarize.js +2 -3
  29. package/package.json +3 -3
  30. package/scripts/changelog-prep.mjs +62 -7
  31. package/scripts/check-sync.js +8 -6
  32. package/scripts/release-docs.mjs +204 -0
  33. package/scripts/release-notes.mjs +162 -0
  34. package/src/api-standalone.js +78 -12
  35. package/src/api.js +282 -57
  36. package/src/candidates.js +454 -454
  37. package/src/config.js +33 -2
  38. package/src/embedding.js +116 -15
  39. package/src/index.js +32 -0
  40. package/src/inject.js +34 -3
  41. package/src/lang.js +124 -0
  42. package/src/pipeline-activities.js +178 -0
  43. package/src/pipeline-attribution.js +165 -0
  44. package/src/pipeline-command.js +279 -0
  45. package/src/pipeline-confirm.js +142 -0
  46. package/src/pipeline-exec.js +174 -0
  47. package/src/pipeline-nudge.js +1281 -0
  48. package/src/pipeline-observations.js +181 -0
  49. package/src/pipeline-observe.js +133 -0
  50. package/src/pipeline-runner.js +346 -0
  51. package/src/pipeline-runs.js +442 -0
  52. package/src/pipeline.js +128 -7
  53. package/src/semantic.js +66 -10
  54. package/src/settings.js +132 -1
  55. package/src/store.js +77 -3
  56. package/src/summarize.js +2 -3
  57. package/test/api-hardening.test.js +173 -0
  58. package/test/api.test.js +98 -1
  59. package/test/changelog-prep.test.js +74 -8
  60. package/test/cli-enums.test.js +37 -0
  61. package/test/client.test.js +152 -3
  62. package/test/docs-config-keys.test.js +65 -0
  63. package/test/helpers/write-admission-samples.js +6 -2
  64. package/test/lib-smoke.test.js +20 -0
  65. package/test/persona-api.test.js +172 -0
  66. package/test/persona-flag.test.js +36 -0
  67. package/test/persona-inject.test.js +145 -0
  68. package/test/persona-settings.test.js +145 -0
  69. package/test/pipeline-activities.test.js +155 -0
  70. package/test/pipeline-attribution.test.js +175 -0
  71. package/test/pipeline-command.test.js +243 -0
  72. package/test/pipeline-confirm.test.js +148 -0
  73. package/test/pipeline-exec.test.js +172 -0
  74. package/test/pipeline-nudge.test.js +1036 -0
  75. package/test/pipeline-observations.test.js +157 -0
  76. package/test/pipeline-observe.test.js +127 -0
  77. package/test/pipeline-r1r2.test.js +149 -0
  78. package/test/pipeline-runner.test.js +442 -0
  79. package/test/pipeline-runs.test.js +767 -0
  80. package/test/pipeline.test.js +137 -6
  81. package/test/reindex-backfill.test.js +83 -4
  82. package/test/release-docs.test.js +130 -0
  83. package/test/release-notes.test.js +240 -0
  84. package/test/release-workflow.test.js +16 -0
  85. package/test/route-policy.test.js +113 -0
  86. package/test/security-doc.test.js +139 -0
  87. package/test/sensitive-scan.test.js +44 -0
  88. package/test/workflow-guards.test.js +156 -0
package/test/api.test.js CHANGED
@@ -682,9 +682,12 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
682
682
  // 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS))
683
683
  // v0.9.0 新增 actionPipelineEnabled(M.O. beta 门控;顶层扁平键,
684
684
  // 走 configFlagValue 默认分支,不需要 NESTED_FLAG_PATHS 条目)。
685
+ // v0.9.0 第二批新增 actionRunEnabled(M.O. 执行记账;同样顶层扁平键)。
686
+ // persona 页新增 personaEnabled(人格注入顶层总闸;顶层扁平键,同上)。
685
687
  // 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
686
688
  // (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
687
- assert.equal(Object.keys(data.effective).length, 59 + 3 + 2 + 1 + 2 + 2 + 2 + 1 + 1 + 1 + 2 + 1 + 2 + 1 + 3 + 2 + 2 + 1 + 1 + 1 + 1 + 2 + 1 + 2 + 1);
689
+ // 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
690
+ assert.equal(Object.keys(data.effective).length, 99);
688
691
  assert.equal(data.effective.dreamSkipInvalid, true);
689
692
  assert.equal(data.effective.allowCrossTypeMerge, false);
690
693
  assert.equal(data.effective.dreamMergeGuard, true);
@@ -721,6 +724,11 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
721
724
  // v0.9.0:M.O. beta 门控默认关——客户端 GET /features 必须能读到这个键
722
725
  // (读不到就静默不注册任何座位),且默认值必须是 false(beta opt-in)。
723
726
  assert.equal(data.effective.actionPipelineEnabled, false);
727
+ // v0.9.0 第二批:M.O. 执行记账同样默认关,且**是独立的一个键**——看图与写账
728
+ // 分开,避免「只想看图」的人不知不觉获得热路径上的写入能力。
729
+ assert.equal(data.effective.actionRunEnabled, false);
730
+ // 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。
731
+ assert.equal(data.effective.personaEnabled, false);
724
732
  // 新增字符串 / URL / 枚举键
725
733
  assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
726
734
  assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
@@ -1863,3 +1871,92 @@ test("GET /api/dsh-mneme/inject-preview: returns last assembly snapshot (issue #
1863
1871
  assert.equal(res.statusCode, 404);
1864
1872
  injector(); // 快照是模块全局:用完即清,不污染后续用例
1865
1873
  });
1874
+
1875
+ // --- /pipeline:观察旁挂能真的挂进投影(CodeRabbit #458 F1 的锁)--------------
1876
+
1877
+ test("GET /api/dsh-mneme/pipeline 带观察旁挂:observationsById 真的进了投影而不是被 catch 吞掉", async () => {
1878
+ // 锁 F1:ids 原本声明在账本分支的块级作用域里,旁挂块一引用就 ReferenceError,
1879
+ // 被 catch 静默吞成「没有观察」——面板上一切正常,fix-step-tool 提议却永远少一类。
1880
+ const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs");
1881
+ const { tmpdir } = await import("node:os");
1882
+ const { join } = await import("node:path");
1883
+ const { saveObservations } = await import("../src/pipeline-observations.js");
1884
+
1885
+ const root = mkdtempSync(join(tmpdir(), "mo-api-f1-"));
1886
+ try {
1887
+ const project = join(root, "proj");
1888
+ const pipeline = {
1889
+ id: "demo-f1",
1890
+ name: "演示F1",
1891
+ project: "someone/repo",
1892
+ version: 0,
1893
+ status: "active",
1894
+ done: "做完",
1895
+ trigger: [{ kind: "phrase", value: "跑一下" }],
1896
+ steps: [{
1897
+ id: "S1",
1898
+ phase: "做",
1899
+ title: "跑测试",
1900
+ when: "有人要跑",
1901
+ do: "npm test",
1902
+ who: "main",
1903
+ tool: "npm test",
1904
+ done: "测试全绿",
1905
+ risk: "R0",
1906
+ needs: [],
1907
+ why: "没有它后面没得跑",
1908
+ evidence: ["跑过"]
1909
+ }]
1910
+ };
1911
+ // 项目层工作流(<project>/.mneme/pipelines/*.json)
1912
+ mkdirSync(join(project, ".mneme", "pipelines"), { recursive: true });
1913
+ writeFileSync(join(project, ".mneme", "pipelines", "demo-f1.json"), JSON.stringify(pipeline));
1914
+ // 运行账:<project>/.mneme/pipelines/<id>.runs.jsonl(有账才走到旁挂分支)。
1915
+ // 行形状走 normalizeRun 契约:run.at/by + step.at 全必填(时间不补默认)。
1916
+ writeFileSync(
1917
+ join(project, ".mneme", "pipelines", "demo-f1.runs.jsonl"),
1918
+ JSON.stringify({ at: "2026-10-17T01:00:00.000Z", by: "main", steps: [{ id: "S1", at: "2026-10-17T01:00:00.000Z", skipped: false, detoured: false, refuted: false }] }) + "\n"
1919
+ );
1920
+ // 观察旁挂:<memoryDir>/observations/<项目键>/demo-f1.jsonl。项目键走 candidates.js
1921
+ // 的 projectKey(projectRoot)(sha256 前 8 位 + 清洗名),与 api.js 同源——不能用
1922
+ // 裸 observations/ 目录,那不是 api 侧会查的桶。
1923
+ const { projectKey } = await import("../src/candidates.js");
1924
+ const memoryDir = join(root, "memory");
1925
+ const odir = join(memoryDir, "observations", projectKey(project));
1926
+ const saved = saveObservations({
1927
+ dir: odir,
1928
+ pipelineId: "demo-f1",
1929
+ confirmAt: "2026-10-17T02:00:00.000Z",
1930
+ activities: [{ key: "git push", count: 2 }]
1931
+ });
1932
+ assert.equal(saved.ok, true);
1933
+
1934
+ const { routes } = setup(undefined, "", { memoryDir });
1935
+ const route = routes.find((r) => r.path === "/api/dsh-mneme/pipeline");
1936
+ const res = new FakeRes();
1937
+ await route.handler(req(`/api/dsh-mneme/pipeline?id=demo-f1&project=${encodeURIComponent(project)}`), res);
1938
+ assert.equal(res.statusCode, 200);
1939
+ const data = JSON.parse(res.body);
1940
+ const row = (data.available || []).find((r) => r.id === "demo-f1");
1941
+ assert.ok(row, "工作流要出现在清单里");
1942
+ assert.ok(data.pipeline, "选中投影要存在");
1943
+ // F1 的直接断言:观察旁挂真的喂进了 suggestions 的判据(修复前 observationsById
1944
+ // 恒为 {}——ReferenceError 被 catch 吞掉,观察永远空)。
1945
+ // 观察里只写了 git push,而 S1 声明 tool=npm test 且账本确认走过 1 次 → 矛盾
1946
+ // 成立,fix-step-tool 必须冒头(下方正向锁);观察为空时它不该冒,那是
1947
+ // pipeline.test.js 的判据锁。
1948
+ // runsState 是账本健康度(RUNS_STATES: absent/empty/ok/partial/broken),
1949
+ // 不是节点状态——账本一行合法 run 即为 ok。节点级 trusted 由 deriveState 管,
1950
+ // 在 summarizePipeline 的 perNode 里,这里不重复锁。
1951
+ assert.equal(row.runsState, "ok", "账本一行合法 run,健康度应是 ok");
1952
+ // suggestions 在投影顶层(不在 pipeline 对象下)。
1953
+ assert.ok(Array.isArray(data.suggestions), "投影必须带 suggestions 数组");
1954
+ // 正向锁:观察里只有 git push、S1 声明 npm test 且确认走过 → fix-step-tool 必须冒头。
1955
+ // F1 未修时 observationsById 恒为空,这条断言必挂——正是要抓的回归。
1956
+ const fix = data.suggestions.find((s) => s.kind === "fix-step-tool");
1957
+ assert.ok(fix, "观察进了判据(npm test 未见于观察)→ 必须有 fix-step-tool 提议");
1958
+ assert.equal(fix.stepId, "S1");
1959
+ } finally {
1960
+ rmSync(root, { recursive: true, force: true });
1961
+ }
1962
+ });
@@ -18,18 +18,84 @@ const hasBareLf = (text) => /(^|[^\r])\n/.test(text);
18
18
  test("LF 输入:插入占位节,行尾保持 LF,旧内容原样保留", () => {
19
19
  const r = insertReleaseSection(LF_HEAD, "0.8.13", "2026-10-03");
20
20
  assert.equal(r.ok, true);
21
- assert.ok(r.text.startsWith("# Changelog\n\n## [0.8.13] - 2026-10-03"), "插在标题之后");
21
+ assert.ok(r.text.startsWith("# Changelog\n\n## [0.8.13] - 2026-10-03"), "没有 [Unreleased] 时插在标题之后");
22
22
  assert.ok(r.text.includes("- (待填)"), "带 release.yml verify 要的占位节");
23
23
  assert.ok(r.text.includes("## [0.8.12] - 2026-10-01"), "旧内容不动");
24
24
  assert.equal(r.text.includes("\r\n"), false, "不该往 LF 文件里混 CRLF");
25
25
  });
26
26
 
27
- test("CRLF 输入:同样能插(原先这里静默 no-op —— 本模块存在的首要理由)", () => {
28
- const r = insertReleaseSection(CRLF_HEAD, "0.8.13", "2026-10-03");
29
- assert.equal(r.ok, true, "CRLF 检出上必须也能插入");
30
- assert.notEqual(r.text, CRLF_HEAD, "绝不返回原文——那正是当初的假成功");
31
- assert.ok(r.text.includes("## [0.8.13] - 2026-10-03"));
32
- assert.equal(hasBareLf(r.text), false, "插入的内容也要用 CRLF,不混行尾");
27
+ test("有 [Unreleased] 时插在它下面,不插在上面", () => {
28
+ // Keep a Changelog 的约定:未发布区在最上,新版本节排在它下面。
29
+ // 插反了会得到「[0.9.1] 在 [Unreleased] 之上」——v0.9.0 那次就是靠事后人工提交
30
+ // (77df0fb)掰回来的,机器算得对的事不该留给人补。
31
+ const src = "# Changelog\n\n## [Unreleased]\n\n## 🐛 修复\n\n- 攒着的条目\n\n## [0.9.0] - 2026-10-17\n\n- 旧版本\n";
32
+ const r = insertReleaseSection(src, "0.9.1", "2026-10-18");
33
+ assert.equal(r.ok, true);
34
+ assert.ok(
35
+ r.text.indexOf("## [Unreleased]") < r.text.indexOf("## [0.9.1] - 2026-10-18"),
36
+ "[Unreleased] 必须排在前面"
37
+ );
38
+ assert.ok(
39
+ r.text.indexOf("## [0.9.1] - 2026-10-18") < r.text.indexOf("## [0.9.0] - 2026-10-17"),
40
+ "新版本节又必须排在上一版之前"
41
+ );
42
+ assert.ok(r.text.includes("- 攒着的条目"), "[Unreleased] 里攒的条目不许丢");
43
+ assert.ok(r.text.includes("- 旧版本"), "旧版本节不许丢");
44
+ assert.equal(r.text.includes("\r\n"), false, "这组输入是 LF,不该混进 CRLF");
45
+ });
46
+
47
+ test("[Unreleased] 攒着条目时整段搬进新版本节,不留空占位节", () => {
48
+ // 原先只插一个「🐛 修复 / (待填)」占位,攒着的条目原地不动:发版后文件里同时存在
49
+ // 「新版本节是空的」和「旧条目还挂在 Unreleased 名下」,搬运这一步照样要人工补
50
+ // (v0.9.0 的 77df0fb 干的就是这件事)。搬位置不搬内容——原样移动、一个字不改。
51
+ const src =
52
+ "# Changelog\n\n## [Unreleased]\n\n## 🐛 修复\n\n- 条目甲\n- 条目乙\n\n## 🔐 安全\n\n- 条目丙\n\n## [0.9.0] - 2026-10-17\n\n- 旧版本\n";
53
+ const r = insertReleaseSection(src, "0.9.1", "2026-10-18");
54
+ assert.equal(r.ok, true);
55
+ assert.equal(r.moved, 3, "三条条目都要搬过去");
56
+ assert.equal(r.movedContent, true, "搬了正文与条目,两个字段都该为真");
57
+ assert.equal(r.text.includes("(待填)"), false, "搬了真条目就不该再留空占位");
58
+ // Unreleased 立刻接新版本节,中间不夹任何内容
59
+ assert.ok(r.text.startsWith("# Changelog\n\n## [Unreleased]\n\n## [0.9.1] - 2026-10-18\n\n## 🐛 修复\n\n- 条目甲"));
60
+ assert.ok(r.text.includes("## 🔐 安全\n\n- 条目丙"), "多个小节整体搬,小节结构不动");
61
+ assert.ok(r.text.indexOf("- 条目丙") < r.text.indexOf("## [0.9.0]"), "全都搬进新节里,不留一条挂在 Unreleased 名下");
62
+ });
63
+
64
+ test("[Unreleased] 正文非空但没有 `- ` 条目时,movedContent 仍为 true(moved 只是计数)", () => {
65
+ // 用 moved 当「有没有搬」的判据是错的:正文非空、但一条 `- ` 都没有时 moved 是 0,
66
+ // 入口会打印「占位节([Unreleased] 本来是空的)」——而它显然不空。两个字段必须分开用。
67
+ const src = "# Changelog\n\n## [Unreleased]\n\n一段没有条目的说明正文。\n\n## [0.9.0] - 2026-10-17\n\n- 旧版本\n";
68
+ const r = insertReleaseSection(src, "0.9.1", "2026-10-18");
69
+ assert.equal(r.ok, true);
70
+ assert.equal(r.moved, 0, "没有 `- ` 开头的行为,条目数是 0");
71
+ assert.equal(r.movedContent, true, "但正文确实搬了,这个字段才是「有没有搬」的判据");
72
+ assert.ok(r.text.includes("一段没有条目的说明正文。"), "正文不许丢");
73
+ assert.equal(r.text.includes("(待填)"), false, "不该在搬了正文之后还留空占位");
74
+ });
75
+
76
+ test("[Unreleased] 首行是缩进代码块时保留前导空格(原样搬,不 trim)", () => {
77
+ // body.trim() 会把首行的四空格吃掉,Markdown 代码块降级成普通段落——
78
+ // 那就不是「整段搬、一个字不改」了。只许剥掉节边界上的空行。
79
+ const src = "# Changelog\n\n## [Unreleased]\n\n npm run sync\n\n## [0.9.0] - 2026-10-17\n\n- 旧版本\n";
80
+ const r = insertReleaseSection(src, "0.9.1", "2026-10-18");
81
+ assert.equal(r.ok, true);
82
+ assert.ok(r.text.includes("\n npm run sync\n"), "四空格缩进必须原样保留:\n" + JSON.stringify(r.text));
83
+ });
84
+
85
+ test("[Unreleased] 为空时插占位节,且不产生多余空行", () => {
86
+ const src = "# Changelog\r\n\r\n## [Unreleased]\r\n## [0.9.0] - 2026-10-17\r\n\r\n- x\r\n";
87
+ const r = insertReleaseSection(src, "0.9.1", "2026-10-18");
88
+ assert.equal(r.ok, true, "CRLF 检出上也要能插");
89
+ assert.equal(r.moved, 0, "Unreleased 是空的,没有可搬的");
90
+ assert.equal(r.movedContent, false, "Unreleased 空时才该走占位节分支");
91
+ assert.ok(
92
+ r.text.startsWith(
93
+ "# Changelog\r\n\r\n## [Unreleased]\r\n\r\n## [0.9.1] - 2026-10-18\r\n\r\n## 🐛 修复\r\n\r\n- (待填)\r\n\r\n## [0.9.0] - 2026-10-17\r\n"
94
+ ),
95
+ "空的 Unreleased 后面接占位节,再接上一版"
96
+ );
97
+ assert.equal(/\n\n\n/.test(r.text.replace(/\r\n/g, "\n")), false, "不许出三连空行");
98
+ assert.equal(hasBareLf(r.text), false);
33
99
  });
34
100
 
35
101
  test("带 BOM 的输入也能插(`^#` 匹配不到属同一类形状漂移)", () => {
@@ -57,4 +123,4 @@ test("detectEol:认两种行尾,无换行时按 LF 处理", () => {
57
123
  assert.equal(detectEol("a\nb"), "\n");
58
124
  assert.equal(detectEol("a\r\nb"), "\r\n");
59
125
  assert.equal(detectEol("a"), "\n");
60
- });
126
+ });
@@ -0,0 +1,37 @@
1
+ // CLI 枚举是手写字面量(bin/cli.mjs 是零依赖 HTTP 客户端,不 import 服务端
2
+ // 模块),没有类型系统兜着,所以用一张文本锁把它钉在 store.TYPES 上。
3
+ // 防的回归:2026-10 审计发现 MEMORY_TYPES 只有 5 个类型、SEARCH_MODES 缺
4
+ // hybrid,结果是 add --type pitfall 与 search --mode hybrid 被客户端自己拒掉
5
+ // ——服务端明明支持,客户端先说不。
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import path from "node:path";
11
+ import { TYPES } from "../src/store.js";
12
+
13
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
14
+ const cli = readFileSync(path.join(root, "bin", "cli.mjs"), "utf8");
15
+
16
+ function literalArray(name) {
17
+ const m = cli.match(new RegExp("const " + name + " = \\[([^\\]]*)\\]"));
18
+ assert.ok(m, name + " 应从 bin/cli.mjs 里解析出来(改名就同步改这把锁)");
19
+ return [...m[1].matchAll(/'([^']+)'/g)].map((x) => x[1]);
20
+ }
21
+
22
+ test("cli MEMORY_TYPES 与 store.TYPES 的差值恰好是 document", () => {
23
+ const cliTypes = literalArray("MEMORY_TYPES");
24
+ // document 只能经 memory_register_document 铸造;放进 --type 会让用户得到一条
25
+ // 服务端 400。锁「差值恰好是 document」而不是「完全相等」,就是为了让这条
26
+ // 例外显式化:未来再加一个只能经专用接口铸造的类型,这里会红。
27
+ const expected = [...TYPES].filter((t) => t !== "document").sort();
28
+ assert.deepEqual(cliTypes.slice().sort(), expected);
29
+ });
30
+
31
+ test("cli SEARCH_MODES 覆盖 API 支持的全部召回模式", () => {
32
+ const modes = literalArray("SEARCH_MODES");
33
+ // 四种模式来自 src/api.js 的 /search mode 注释(auto/hybrid/vector/keyword)。
34
+ for (const mode of ["keyword", "vector", "auto", "hybrid"]) {
35
+ assert.ok(modes.includes(mode), "SEARCH_MODES 缺少 " + mode);
36
+ }
37
+ });
@@ -1020,10 +1020,14 @@ test("scope isolation ships panel switches and provenance surfaces (A4)", () =>
1020
1020
  // 默认值),面板自判、不加后端字段;④分组与折叠键中英成对。验收口径:首屏
1021
1021
  // 可点项 = 3 总开关 + autoInject 的 2 个子开关 + scope 2 开关 + 「全部设置」
1022
1022
  // 按钮 = 8(≤ 8),由 ①② 的组成正则共同钉死,不另数 DOM。
1023
+ // v0.9.0 复验修订:首屏加 group.mo(M.O. 独立组),可点项预算 8→9。这不是
1024
+ // 悄悄放宽——是产品主人点名「别藏在里面」:M.O. 独立成组却仍折在「全部
1025
+ // 设置」后等于换个地方继续藏。≤8 原为 #427 自设软预算,owner 豁免一项。
1023
1026
  test("settings card folds everything beyond the first screen behind All settings (issue #427)", () => {
1024
- // ① 首屏层组成:两组都要在,且 basic 的成员是从 core/dream 移走的。
1025
- assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.scope"\];/.test(clientSource),
1026
- "the first screen must be exactly the basic and scope groups");
1027
+ // ① 首屏层组成:三组都要在(basic 总开关 / mo 座位闸门 / scope 必答题),
1028
+ // 且 basic 的成员是从 core/dream 移走的。
1029
+ assert.ok(/const FEATURE_FIRST_SCREEN = \["group\.basic", "group\.mo", "group\.scope"\];/.test(clientSource),
1030
+ "the first screen must be exactly the basic, mo and scope groups");
1027
1031
  assert.ok(/key: "group\.basic", items: \["autoInject", "autoSummarize", "autoDream"\]/.test(clientSource),
1028
1032
  "group.basic must carry exactly the three master switches");
1029
1033
  assert.ok(!/key: "group\.core", items: \["autoInject/.test(clientSource) && !/key: "group\.dream", items: \["autoDream/.test(clientSource),
@@ -1939,3 +1943,148 @@ test("M.O. class names do not collide in the stylesheet", () => {
1939
1943
  // 扫描量也得钉:正则一旦匹配不到东西,上面那条会静默变绿(例如样式表被整块挪走)。
1940
1944
  assert.ok(declared.size >= 20, `the scan must actually see the pipeline stylesheet (saw ${declared.size})`);
1941
1945
  });
1946
+
1947
+ // 回归锁(v0.9.0 复验轮沉淀):面板的两种「静默消失」假绿形状一起钉住。
1948
+ // ① i18n 缺定义:既有的「每键恰好两次」锁只统计**字典里存在**的键,一个只有
1949
+ // 用法、零字典行的键在计数里整个消失——本轮就查出 4 个这种键(zoom.value 自
1950
+ // 基线即断)。所以这里反向查:每一处用法都必须能找到定义行。三种用法形状:
1951
+ // 字面量 t("k")、模板 t(`memory.features.${g.key}`) 按 FEATURE_GROUPS 的组键
1952
+ // 展开、模板 t(`memory.features.${k}`) 按各 items 旗标键展开(boolRow 的
1953
+ // name/hint 各一路)。不做全量动态键解析——拼接形态的键在此设防范围外。
1954
+ // ② 图标名未注册:Icon 对查不到的名字 return null(无回退、无告警),闸门照绿
1955
+ // 而屏幕上图标没了。本轮 star 入口就是这么哑掉的。这里钉「每个用到的名字都在
1956
+ // ICON_PATHS 注册表里」+ 提取与注册表的最低数量(防正则空转的静默绿)。
1957
+ test("panel copy and icons must not silently vanish (missing dict lines, unregistered icon names)", () => {
1958
+ const defined = (key) => clientSource.includes('"' + key + '":');
1959
+
1960
+ // ①-a 字面量用法(右括号紧贴字符串,排除 "前缀" + 变量 拼接形态)
1961
+ const usedKeys = new Set();
1962
+ for (const m of clientSource.matchAll(/\bt\("([a-zA-Z0-9_.]+)"\)/g)) usedKeys.add(m[1]);
1963
+ assert.ok(usedKeys.size >= 300, `the literal scan must actually see the panel's t() calls (saw ${usedKeys.size})`);
1964
+ // ①-b 模板展开:组键 与 旗标键(label + .hint 两路)
1965
+ const groupKeys = [...clientSource.matchAll(/key: "(group\.[a-z]+)"/g)].map((m) => m[1]);
1966
+ assert.ok(groupKeys.length >= 5, `FEATURE_GROUPS must be parseable (saw ${groupKeys.length} groups)`);
1967
+ for (const g of groupKeys) usedKeys.add("memory.features." + g);
1968
+ const flagKeys = [...new Set([...clientSource.matchAll(/items: \[((?:[^\]]|\n)*)\]/g)]
1969
+ .flatMap((m) => [...m[1].matchAll(/"([a-zA-Z0-9_.]+)"/g)].map((x) => x[1])))];
1970
+ assert.ok(flagKeys.length >= 20, `group items must be parseable (saw ${flagKeys.length})`);
1971
+ for (const k of flagKeys) { usedKeys.add("memory.features." + k); usedKeys.add("memory.features." + k + ".hint"); }
1972
+
1973
+ const missing = [...usedKeys].filter((k) => !defined(k)).sort();
1974
+ assert.deepEqual(missing, [], `用而无典的 i18n 键(界面会裸奔出键名):${missing.join(", ")}`);
1975
+
1976
+ // ② 图标名:注册表形状(顶层对象、六空格缩进)+ Icon 表达式与 feedRow 首参的提取
1977
+ const registered = new Set([...clientSource.matchAll(/^\s{6}([a-zA-Z]+): \[\[/gm)].map((m) => m[1]));
1978
+ assert.ok(registered.size >= 10, `the ICON_PATHS registry must be parseable (saw ${registered.size})`);
1979
+ const iconNames = new Set();
1980
+ for (const m of clientSource.matchAll(/h\(Icon, \{ name: ([^}]+?) [,}]/g)) {
1981
+ const expr = m[1];
1982
+ const direct = expr.match(/^"([a-zA-Z]+)"$/);
1983
+ if (direct) { iconNames.add(direct[1]); continue; }
1984
+ // 只取三元分支的纯字母串;"===" 比较对象前面是运算符不是 ? : ,不会误伤
1985
+ for (const b of expr.matchAll(/[?:]\s*"([a-zA-Z]+)"/g)) iconNames.add(b[1]);
1986
+ }
1987
+ for (const m of clientSource.matchAll(/feedRow\(\s*"([a-zA-Z]+)"/g)) iconNames.add(m[1]);
1988
+ assert.ok(iconNames.size >= 8, `icon usage must be parseable (saw ${iconNames.size})`);
1989
+ const noIcon = [...iconNames].filter((n) => !registered.has(n)).sort();
1990
+ assert.deepEqual(noIcon, [], `Icon 静默 return null 的未注册名:${noIcon.join(", ")}`);
1991
+ });
1992
+
1993
+ // 人格定义页(beta)面板面回归:形状沿用 A4 的「分组 + i18n 双语 occurrences ≥ 2
1994
+ // + 关键接线」断言风格(只做源码断言,不挂 React 渲染环境)。
1995
+ test("persona page ships settings card, master switch and i18n (beta v1)", () => {
1996
+ // 开关进 FEATURE_GROUPS 的 enhance 组(行为开关非座位闸门)
1997
+ assert.ok(
1998
+ /group\.enhance", items: \[(?:[^\]]|\n)*"personaEnabled"\]/.test(clientSource),
1999
+ "FEATURE_GROUPS group.enhance must carry personaEnabled"
2000
+ );
2001
+ // i18n 键中英双语齐备(occurrences ≥ 2 模式;label + .hint 两路由字典锁统一覆盖)
2002
+ for (const key of [
2003
+ "memory.features.personaEnabled",
2004
+ "memory.features.personaEnabled.hint",
2005
+ "memory.settings.persona",
2006
+ "memory.settings.personaHint",
2007
+ "memory.settings.personaName",
2008
+ "memory.settings.personaPersonality",
2009
+ "memory.settings.personaReplyStyle",
2010
+ "memory.settings.personaSave",
2011
+ "memory.settings.personaSaved",
2012
+ "memory.settings.personaEnabledToggle",
2013
+ "memory.settings.personaView",
2014
+ "memory.settings.personaViewHint",
2015
+ "memory.settings.personaViewEmpty"
2016
+ ]) {
2017
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2018
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2019
+ }
2020
+ // 设置卡接线:保存走 PUT /persona、卡内开关改 enabled 即保存、只读卡渲染
2021
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/persona"'), "persona card must talk to /persona");
2022
+ assert.ok(clientSource.includes("method: \"PUT\""), "persona save must PUT");
2023
+ assert.ok(clientSource.includes("t(\"memory.settings.personaView\")"), "read-only injected-view card must exist");
2024
+ // 双闸门提示:hint 文案必须说明「总闸与卡内开关同时打开才生效」
2025
+ assert.ok(
2026
+ clientSource.includes("memory.features.personaEnabled.hint"),
2027
+ "the master-switch hint must document the two-gate semantics"
2028
+ );
2029
+ });
2030
+
2031
+ // v1.2 多卡回归:卡列表 CRUD 可见、字段标签渲染为可见元素(v1.1 反馈:
2032
+ // aria-label 看不见)、保存失败必须冒可见错误(v1 静默 catch 吞 404 教训)。
2033
+ test("persona card is multi-card with visible field labels and save-failure error", () => {
2034
+ for (const key of [
2035
+ "memory.settings.personaDefaultCard",
2036
+ "memory.settings.personaAdd",
2037
+ "memory.settings.personaAddPlaceholder",
2038
+ "memory.settings.personaEditingDefault",
2039
+ "memory.settings.personaEditingFor",
2040
+ "memory.settings.personaDeleteCard",
2041
+ "memory.settings.personaSaveFailed",
2042
+ "memory.settings.personaEnabledOn",
2043
+ "memory.settings.personaEnabledOff"
2044
+ ]) {
2045
+ const occurrences = clientSource.split(`"${key}"`).length - 1;
2046
+ assert.ok(occurrences >= 2, `i18n key ${key} must exist in both zh and en (got ${occurrences})`);
2047
+ }
2048
+ // 多卡接线:加卡/删卡/切换/保存全链函数存在
2049
+ for (const fn of ["addPersonaCard", "removePersonaCard", "savePersonaCards", "setPersonaEditing"]) {
2050
+ assert.ok(clientSource.includes(fn), `multi-card wiring must include ${fn}`);
2051
+ }
2052
+ // 可见字段标签:三个字段名的 t() 调用出现在 label 元素(mneme-featnumlabel)
2053
+ const labelBlock = clientSource.match(/mneme-featnumlabel[^]{0,120}personaName/);
2054
+ assert.ok(labelBlock, "persona field labels must be visible (featnumlabel class), not aria-only");
2055
+ // 保存失败提示:personaError 状态有渲染路径(非仅 setState)
2056
+ assert.ok(clientSource.includes("t(\"memory.settings.personaSaveFailed\")"), "save failure must render an error line");
2057
+ });
2058
+
2059
+ // 回归锁(复验实测踩中):面板「检测更新」的状态名必须与后端 classify
2060
+ // (src/version-check.js)逐字对齐。此前前端误写 "latest"/"up_to_date"(后端
2061
+ // 从未返回过),检测成功也掉进 error 兜底——按钮永远报「检测失败」。
2062
+ // 这条缝两端各有测试(version-check.test.js 测 classify、面板测试测渲染),
2063
+ // 但契约本身没人管。锁三件事:①前端比较的字面量是后端真实返回值;②后端
2064
+ // classify 的返回集合没有前端漏接的成员;③apiFetch 的路由路径与后端注册一致。
2065
+ test("update-check status names match the backend classify contract verbatim", () => {
2066
+ const vcSource = readFileSync(join(root, "src/version-check.js"), "utf8");
2067
+ // ① 后端 classify 的全部返回字面量
2068
+ const backendStatuses = new Set(
2069
+ [...vcSource.matchAll(/return "([a-z-]+)";/g)].map((m) => m[1])
2070
+ );
2071
+ assert.ok(backendStatuses.has("up-to-date") && backendStatuses.has("outdated") && backendStatuses.has("ahead") && backendStatuses.has("unknown"),
2072
+ `classify must return the known status set (saw ${[...backendStatuses].join(", ")})`);
2073
+ // ② 前端 checkResultText 比较的每个状态名都必须 ∈ 后端集合(防再发明新名)
2074
+ const iStart = clientSource.indexOf("function checkResultText");
2075
+ assert.ok(iStart > 0, "checkResultText must exist");
2076
+ // 函数块取到下一个同级 function 声明为止:indexOf("}") 会切在首个 if 的
2077
+ // 右括号,比较分支被截断成 1 个(2026-10-17 实测)。
2078
+ const iEnd = clientSource.slice(iStart).search(/\n {6}function /);
2079
+ const block = clientSource.slice(iStart, iEnd > 0 ? iStart + iEnd : undefined);
2080
+ const compared = [...block.matchAll(/updateInfo\.status === "([a-zA-Z_-]+)"/g)].map((m) => m[1]);
2081
+ assert.ok(compared.length >= 2, `the panel must compare real statuses (saw ${compared.length})`);
2082
+ const unknown = [...new Set(compared)].filter((s) => !backendStatuses.has(s));
2083
+ assert.deepEqual(unknown, [], `前端比较了后端不会返回的状态名(检测成功也会显示失败):${unknown.join(", ")}`);
2084
+ // ③ 路由契约:前端 fetch 的路径必须与后端注册的 exact path 一致
2085
+ assert.ok(clientSource.includes('apiFetch("/api/dsh-mneme/version-check")'),
2086
+ "the panel must call the registered version-check route");
2087
+ const apiSource = readFileSync(join(root, "src/api.js"), "utf8");
2088
+ assert.ok(apiSource.includes('path: "/api/dsh-mneme/version-check"'),
2089
+ "the backend must register the version-check route");
2090
+ });
@@ -0,0 +1,65 @@
1
+ // 「CONFIGURATION.md 是全键参考」这句声明需要一条闸门,否则它会慢慢变成谎话:
2
+ // 2026-10 审计实测有 11 个 schema 键(graph 组 8 个、sensitiveScanEnabled、
3
+ // injectTimePrefix、actionPipelineEnabled)在文档与双 README 里一个字都没有,
4
+ // 而它们里有安全开关。锁法:从 Config({}) 的默认值深挖叶子键,要求每个键名
5
+ // (或它的父路径,用于 heatTypeDecay 这类整表描述)在文档里出现。
6
+ import test from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { readFileSync } from "node:fs";
9
+ import { fileURLToPath } from "node:url";
10
+ import path from "node:path";
11
+ import { Config } from "../src/config.js";
12
+
13
+ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
14
+ const doc = readFileSync(path.join(root, "docs", "CONFIGURATION.md"), "utf8");
15
+ const cfgSrc = readFileSync(path.join(root, "src", "config.js"), "utf8");
16
+
17
+ function leafKeys(obj, prefix) {
18
+ const out = [];
19
+ for (const [k, v] of Object.entries(obj)) {
20
+ const p = prefix ? prefix + "." + k : k;
21
+ if (v && typeof v === "object" && !Array.isArray(v)) { out.push(...leafKeys(v, p)); continue; }
22
+ out.push({ path: p, leaf: k, parent: prefix || "" });
23
+ }
24
+ return out;
25
+ }
26
+
27
+ // 父路径回退是给「整表说明」用的例外,白名单写死在这里而不是「任何父路径都
28
+ // 放行」——否则往 writeAdmission 里加个子键,只要文档提到 writeAdmission 就过,
29
+ // 测试原本要防的漏键会静默复发(评审指出的漏洞)。新增例外必须显式加一行。
30
+ const PARENT_ONLY_OK = new Set(["heatTypeDecay"]);
31
+
32
+ test("CONFIGURATION.md 覆盖 schema 的每一个键", () => {
33
+ const keys = leafKeys(Config({}), "");
34
+ assert.ok(keys.length > 150, "键枚举失败会静默返回 0 项,先锁下限");
35
+ const missing = keys.filter((k) => {
36
+ // 完整路径优先("writeAdmission.enabled"),其次才是显式白名单里的整表父路径。
37
+ if (doc.includes(k.path)) return false;
38
+ if (k.parent && PARENT_ONLY_OK.has(k.parent) && doc.includes(k.parent)) return false;
39
+ // 表里写的是裸键名也认(绝大多数顶层键就是这么写的)。
40
+ return !doc.includes(k.leaf);
41
+ });
42
+ assert.deepEqual(missing.map((m) => m.path), []);
43
+ });
44
+
45
+ // lightMode 清单是有边界的一节:整份文档 grep 会让「删掉清单里的某一行」照样
46
+ // 通过(图谱表里还留着同名键),测试要盯的漏项就藏在那个缝里。
47
+ function lightSection(text) {
48
+ const start = text.indexOf("lightMode 强制关闭清单");
49
+ assert.ok(start >= 0, "文档里应有「lightMode 强制关闭清单」一节");
50
+ const rest = text.slice(start);
51
+ const end = rest.indexOf("预设只是默认值");
52
+ return end > 0 ? rest.slice(0, end) : rest;
53
+ }
54
+
55
+ test("LIGHT_MODE_OFF 的每一项都写进了文档的轻量档清单", () => {
56
+ // LIGHT_MODE_OFF 没有导出,只能解析源码文本;先锁解析结果的长度,防止
57
+ // 正则失配时这个用例变成「空数组 ⊂ 文档」的永真断言。
58
+ const m = cfgSrc.match(/const LIGHT_MODE_OFF = \[([\s\S]*?)\];/);
59
+ assert.ok(m, "LIGHT_MODE_OFF 应从 src/config.js 中解析出来");
60
+ const names = [...m[1].matchAll(/"([^"]+)"/g)].map((x) => x[1]);
61
+ assert.ok(names.length >= 20, "解析到 " + names.length + " 项,少于预期的 21 项");
62
+ const section = lightSection(doc);
63
+ const missing = names.filter((n) => !section.includes(n));
64
+ assert.deepEqual(missing, []);
65
+ });
@@ -47,7 +47,11 @@ export const SAMPLE_VALUES = {
47
47
  ".eyJzdWIiOiIxMjM0NTY3ODkwIn0",
48
48
  ".dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U"
49
49
  ),
50
- stripeKey: assemble("sk_", "live_", "51H8Qw2KZvN3mPqRsTuVwXyZ0123456789abcdef")
50
+ stripeKey: assemble("sk_", "live_", "51H8Qw2KZvN3mPqRsTuVwXyZ0123456789abcdef"),
51
+ // 这条曾以整串字面量留在下面的 content 里,被 GitHub secret scanning 报成 openai_api_key
52
+ // (alert #1)。它是形状样本不是真凭据(正文就是 a-z、0-9 顺序串),但扫描器只看形状,
53
+ // 所以按上面第 32-34 行的口径统一走拼接,别让它再当例外。
54
+ openaiKey: assemble("sk-proj-", "abcdefghijklmnopqrstuvwxyz0123456789ABCD")
51
55
  };
52
56
 
53
57
  /** 密钥凭据类正样本。kind 与 label 是判据该报回来的东西。 */
@@ -73,7 +77,7 @@ export const SECRET_SAMPLES = [
73
77
  kind: "openai_key",
74
78
  label: "OpenAI API key",
75
79
  title: "调试记录",
76
- content: "临时用 sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD 调了一次",
80
+ content: `临时用 ${SAMPLE_VALUES.openaiKey} 调了一次`,
77
81
  why: "sk-/sk-proj- 前缀 + 长串"
78
82
  },
79
83
  {
@@ -40,6 +40,26 @@ test("src/ and lib/ are byte-identical (no publish drift)", () => {
40
40
  assert.deepEqual(drift, [], "src↔lib must stay in sync (npm run sync)");
41
41
  });
42
42
 
43
+ test("没有文件带双 CR(行尾必须是 LF 或 CRLF,不是 CRCRLF)", () => {
44
+ // 这条锁的来历:`src/candidates.js` 从诞生起每个换行都是 `<CR><CR><LF>`,
45
+ // 一路进了 main、又随 v0.9.0 发到 npm,而**当时没有任何检查会红**——
46
+ // 因为 `\r` 在 JS 里是合法空白,解析与测试全绿。代价落在别处:
47
+ // · 任何改动这个文件的人,diff 都是整文件 908/908(噪声掩盖真改动);
48
+ // · 同一份文件 `Get-Content` 数出 908 行、编辑器/工具数出 454 行,两边对不上;
49
+ // · 发布产物里带着畸形行尾。
50
+ // 判据是**扫描字节**而不是依赖某个工具的换行解释——正因工具口径不一致,
51
+ // 才必须查 `<CR><CR><LF>` 这个具体序列本身。
52
+ const bad = [];
53
+ for (const dir of [srcDir, libDir]) {
54
+ for (const file of walk(dir)) {
55
+ if (!file.endsWith(".js")) continue;
56
+ const doubleCr = readFileSync(file, "utf8").match(/\r\r\n/g);
57
+ if (doubleCr) bad.push(`${relative(root, file)}: ${doubleCr.length} 处`);
58
+ }
59
+ }
60
+ assert.deepEqual(bad, [], "行尾出现 <CR><CR><LF>——多出来的 CR 会让 diff 与行数口径一起坏掉");
61
+ });
62
+
43
63
  // ---------------------------------------------------------------------------
44
64
  // lib 运行时冒烟:直接加载 npm 实际分发的产物
45
65
  // ---------------------------------------------------------------------------