@modusensus/dsh-mneme 0.9.0 → 0.9.2
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.
- package/README.md +82 -19
- package/bin/cli.mjs +69 -9
- package/docs/CONFIGURATION.md +48 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +339 -59
- package/lib/candidates.js +454 -454
- package/lib/client.js +669 -256
- package/lib/config.js +50 -2
- package/lib/dream/sleep.js +35 -12
- package/lib/dream.js +86 -14
- package/lib/embedding.js +153 -22
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +128 -7
- package/lib/scope-state.js +277 -0
- package/lib/semantic.js +66 -10
- package/lib/service.js +80 -3
- package/lib/settings.js +139 -1
- package/lib/store.js +216 -3
- package/lib/summarize.js +2 -3
- package/lib/tools.js +71 -0
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +339 -59
- package/src/candidates.js +454 -454
- package/src/config.js +50 -2
- package/src/dream/sleep.js +35 -12
- package/src/dream.js +86 -14
- package/src/embedding.js +153 -22
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +128 -7
- package/src/scope-state.js +277 -0
- package/src/semantic.js +66 -10
- package/src/service.js +80 -3
- package/src/settings.js +139 -1
- package/src/store.js +216 -3
- package/src/summarize.js +2 -3
- package/src/tools.js +71 -0
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +210 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/cli-flag-value.test.js +86 -0
- package/test/client.test.js +216 -6
- package/test/distill-tx-embed-gap.test.js +110 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/dream-maxtokens-fallback.test.js +132 -0
- package/test/embed-text-reasons.test.js +112 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +137 -6
- package/test/readme-claims.test.js +68 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-state.test.js +481 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/tools-dto-schema-matrix.test.js +12 -3
- package/test/tools.test.js +6 -6
- package/test/workflow-guards.test.js +156 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// 2026-10 供应链与代码安全审计的回归锁(宿主内 API 面)。
|
|
2
|
+
// 锁的是四件事,防的回归各写在各用例注释里:
|
|
3
|
+
// 1. /export 不再对任何调用方开放(审计 P0:配了 apiToken 也照旧全库导出)
|
|
4
|
+
// 2. /external-api 的 GET 与 PUT 同门(审计 P0:未鉴权 GET 会明文回吐 8790 的
|
|
5
|
+
// Bearer,而且顺手把 token 铸造进库——一条不需要凭证的写路径)
|
|
6
|
+
// 3. list/search 的分页参数有上界(免鉴权读面 + 浏览器简单请求 = 廉价 DoS)
|
|
7
|
+
// 4. readBody 不再无界累积(4 MiB 上限,超限走 400 而不是把内存交给调用方)
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { EventEmitter } from "node:events";
|
|
11
|
+
import { createStore } from "../src/store.js";
|
|
12
|
+
import { createService } from "../src/service.js";
|
|
13
|
+
import { createApi } from "../src/api.js";
|
|
14
|
+
import { createSettings } from "../src/settings.js";
|
|
15
|
+
|
|
16
|
+
class FakeRes extends EventEmitter {
|
|
17
|
+
constructor() { super(); this.statusCode = 200; this.body = ""; }
|
|
18
|
+
writeHead(code, headers) { this.statusCode = code; this.headers = headers; return this; }
|
|
19
|
+
end(text) { this.body = text ?? ""; this.emit("end"); return this; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function req(path, method = "GET", body = null) {
|
|
23
|
+
const r = new EventEmitter();
|
|
24
|
+
r.url = path;
|
|
25
|
+
r.method = method;
|
|
26
|
+
r.headers = {};
|
|
27
|
+
if (body !== null) {
|
|
28
|
+
process.nextTick(() => {
|
|
29
|
+
r.emit("data", Buffer.from(JSON.stringify(body)));
|
|
30
|
+
r.emit("end");
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return r;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setup(apiToken = "") {
|
|
37
|
+
const store = createStore(":memory:");
|
|
38
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
39
|
+
const settings = createSettings(store.db);
|
|
40
|
+
const commands = { add: () => {}, remove: () => {}, list: () => [] };
|
|
41
|
+
const routes = [];
|
|
42
|
+
const ctx = { webServer: { register(route) { routes.push(route); return () => {}; } } };
|
|
43
|
+
createApi(ctx, service, settings, commands, null, undefined, apiToken);
|
|
44
|
+
return { store, service, routes, settings };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function handler(routes, path) {
|
|
48
|
+
return routes.find((r) => r.path === path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
test("/export 在配置 apiToken 后必须带 Bearer", async () => {
|
|
52
|
+
// 审计 P0:这条路由长期没有鉴权分支,即使配了 token 也把全库(含
|
|
53
|
+
// archived/forgotten 全字段)交给任何能打到端口的人。
|
|
54
|
+
const { routes, service } = setup("secret-token");
|
|
55
|
+
service.saveWithDedupe({ type: "preference", title: "甲", content: "内容甲" });
|
|
56
|
+
const route = handler(routes, "/api/dsh-mneme/export");
|
|
57
|
+
|
|
58
|
+
let res = new FakeRes();
|
|
59
|
+
await route.handler(req("/api/dsh-mneme/export"), res);
|
|
60
|
+
assert.equal(res.statusCode, 401, "无 token → 401");
|
|
61
|
+
assert.equal(JSON.parse(res.body).error, "unauthorized");
|
|
62
|
+
|
|
63
|
+
res = new FakeRes();
|
|
64
|
+
const ok = req("/api/dsh-mneme/export?format=json");
|
|
65
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
66
|
+
await route.handler(ok, res);
|
|
67
|
+
assert.equal(res.statusCode, 200, "带对 token → 200");
|
|
68
|
+
assert.equal(JSON.parse(res.body).count, 1);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("/external-api 的 GET 与 PUT 同门,未鉴权不铸造 token", async () => {
|
|
72
|
+
// 两个回归一起锁:①未鉴权 GET 能把 8790 的 Bearer 明文读走;②那次 GET 还会
|
|
73
|
+
// 顺手 randomBytes 铸造并持久化一个 token——没有凭证的写路径。
|
|
74
|
+
const { routes, settings } = setup("secret-token");
|
|
75
|
+
const route = handler(routes, "/api/dsh-mneme/external-api");
|
|
76
|
+
|
|
77
|
+
const res = new FakeRes();
|
|
78
|
+
await route.handler(req("/api/dsh-mneme/external-api"), res);
|
|
79
|
+
assert.equal(res.statusCode, 401);
|
|
80
|
+
assert.equal(settings.getExternalApi()?.token, undefined, "未鉴权请求不得铸造 token");
|
|
81
|
+
|
|
82
|
+
const ok = req("/api/dsh-mneme/external-api");
|
|
83
|
+
ok.headers = { authorization: "Bearer secret-token" };
|
|
84
|
+
const res2 = new FakeRes();
|
|
85
|
+
await route.handler(ok, res2);
|
|
86
|
+
assert.equal(res2.statusCode, 200);
|
|
87
|
+
assert.ok(JSON.parse(res2.body).config.token.length >= 16, "鉴权后照旧发 token");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("list 的 limit 被上界钳制", async () => {
|
|
91
|
+
// 上界是防御:limit 直接进 SQL LIMIT,?limit=100000 会让一次免鉴权 GET 把
|
|
92
|
+
// 全库序列化出来(浏览器 <img> 就能触发)。
|
|
93
|
+
const { routes, service } = setup();
|
|
94
|
+
let seen = null;
|
|
95
|
+
const original = service.list;
|
|
96
|
+
service.list = (args) => { seen = args; return []; };
|
|
97
|
+
const res = new FakeRes();
|
|
98
|
+
await handler(routes, "/api/dsh-mneme/list").handler(req("/api/dsh-mneme/list?limit=100000"), res);
|
|
99
|
+
assert.equal(res.statusCode, 200);
|
|
100
|
+
assert.equal(seen.limit, 1000);
|
|
101
|
+
service.list = original;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("search 的 topK 被上界钳制", async () => {
|
|
105
|
+
const { routes, service } = setup();
|
|
106
|
+
let seen = null;
|
|
107
|
+
const original = service.searchMemories;
|
|
108
|
+
service.searchMemories = (q, opts) => { seen = opts; return []; };
|
|
109
|
+
const res = new FakeRes();
|
|
110
|
+
await handler(routes, "/api/dsh-mneme/search").handler(req("/api/dsh-mneme/search?q=abc&topK=100000"), res);
|
|
111
|
+
assert.equal(res.statusCode, 200);
|
|
112
|
+
assert.equal(seen.topK, 200);
|
|
113
|
+
service.searchMemories = original;
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
function oversizedPost(path, method = "POST") {
|
|
117
|
+
const huge = Buffer.from("{\"content\":\"" + "x".repeat(5 * 1024 * 1024) + "\"}");
|
|
118
|
+
const r = new EventEmitter();
|
|
119
|
+
r.url = path;
|
|
120
|
+
r.method = method;
|
|
121
|
+
r.headers = {};
|
|
122
|
+
process.nextTick(() => { r.emit("data", huge); r.emit("end"); });
|
|
123
|
+
return r;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
test("超大请求体回 413,且不落库", async () => {
|
|
127
|
+
// 4 MiB 上限。断言必须是**明确的 413**:旧写法只断言 notEqual(200),而 /create
|
|
128
|
+
// 成功时回 201——一个真被写进去的超限请求也能让那条断言通过(评审指出的空断言)。
|
|
129
|
+
const { routes, service } = setup();
|
|
130
|
+
const before = service.all().length;
|
|
131
|
+
const res = new FakeRes();
|
|
132
|
+
await handler(routes, "/api/dsh-mneme/create").handler(oversizedPost("/api/dsh-mneme/create"), res);
|
|
133
|
+
assert.equal(res.statusCode, 413);
|
|
134
|
+
assert.equal(JSON.parse(res.body).error, "payload-too-large");
|
|
135
|
+
assert.equal(service.all().length, before, "超限请求不得留下任何记忆");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("超限后又来 transport error,仍回 413 且不写库", async () => {
|
|
139
|
+
// readBody 的超限状态必须活过 error 分支:旧实现 error 时 resolve(""),
|
|
140
|
+
// 调用点只认 null,于是拿着空串继续走 → PUT /persona 把 "" 解析成 {},
|
|
141
|
+
// 覆盖既有设置。这条锁的就是「413 发出去了,但后面的写入还在跑」。
|
|
142
|
+
const { routes, settings } = setup("secret-token");
|
|
143
|
+
settings.setRules(["别丢"]);
|
|
144
|
+
const huge = Buffer.from("x".repeat(5 * 1024 * 1024));
|
|
145
|
+
const r = new EventEmitter();
|
|
146
|
+
r.url = "/api/dsh-mneme/rules";
|
|
147
|
+
r.method = "PUT";
|
|
148
|
+
r.headers = { authorization: "Bearer secret-token" };
|
|
149
|
+
const res = new FakeRes();
|
|
150
|
+
const done = handler(routes, "/api/dsh-mneme/rules").handler(r, res);
|
|
151
|
+
process.nextTick(() => { r.emit("data", huge); r.emit("error", new Error("boom")); });
|
|
152
|
+
await done;
|
|
153
|
+
assert.equal(res.statusCode, 413);
|
|
154
|
+
assert.deepEqual(settings.getRules(), ["别丢"], "error 分支不得让空体落库");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("超大请求体不会清空画像/规则", async () => {
|
|
158
|
+
// 这是最要命的一条:readBody 旧实现超限回空串 → parseBody 得到 {} →
|
|
159
|
+
// setProfile("") / setRules([]) 把既有内容静默清掉。413 必须在碰 store 之前。
|
|
160
|
+
const { routes, settings } = setup("secret-token");
|
|
161
|
+
settings.setProfile("原有画像");
|
|
162
|
+
settings.setRules(["原有规则"]);
|
|
163
|
+
|
|
164
|
+
for (const [path, method] of [["/api/dsh-mneme/profile", "PUT"], ["/api/dsh-mneme/rules", "PUT"]]) {
|
|
165
|
+
const res = new FakeRes();
|
|
166
|
+
const req = oversizedPost(path, method);
|
|
167
|
+
req.headers = { authorization: "Bearer secret-token" };
|
|
168
|
+
await handler(routes, path).handler(req, res);
|
|
169
|
+
assert.equal(res.statusCode, 413, path + " 超限应回 413");
|
|
170
|
+
}
|
|
171
|
+
assert.equal(settings.getProfile(), "原有画像", "超限请求不得清空画像");
|
|
172
|
+
assert.deepEqual(settings.getRules(), ["原有规则"], "超限请求不得清空规则");
|
|
173
|
+
});
|
package/test/api.test.js
CHANGED
|
@@ -538,6 +538,63 @@ test("GET /api/dsh-mneme/semantic reports configured/coverage/degraded", async (
|
|
|
538
538
|
assert.equal(noIndex.degraded, false);
|
|
539
539
|
});
|
|
540
540
|
|
|
541
|
+
// --- distill-tx-embed-gap 后续:/semantic 区分「有向量」与「可检索」 -----------
|
|
542
|
+
|
|
543
|
+
test("GET /api/dsh-mneme/semantic reports per-dimension/per-model vector breakdown", async () => {
|
|
544
|
+
const { routes, store, service } = setup(undefined);
|
|
545
|
+
// 三种状态混进一个库:3 维 2 条、旧 4 维 1 条、不可解析 1 条;
|
|
546
|
+
// 模型出身 m1#aaa 一行、m2#bbb 一行、无出身两行。
|
|
547
|
+
const a = service.saveWithDedupe({ type: "preference", title: "同维A", content: "x" }).memory;
|
|
548
|
+
const b = service.saveWithDedupe({ type: "preference", title: "同维B", content: "x" }).memory;
|
|
549
|
+
const c = service.saveWithDedupe({ type: "preference", title: "旧维C", content: "x" }).memory;
|
|
550
|
+
const d = service.saveWithDedupe({ type: "preference", title: "坏D", content: "x" }).memory;
|
|
551
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), a.id);
|
|
552
|
+
store.db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), "m1#aaa", b.id);
|
|
553
|
+
store.db.prepare("UPDATE memories SET embedding = ?, embedding_model = ? WHERE id = ?").run(JSON.stringify([1, 2, 3, 4]), "m2#bbb", c.id);
|
|
554
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run("not-json", d.id);
|
|
555
|
+
|
|
556
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
557
|
+
const res = new FakeRes();
|
|
558
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
559
|
+
assert.equal(res.statusCode, 200);
|
|
560
|
+
const data = JSON.parse(res.body);
|
|
561
|
+
|
|
562
|
+
// 无 embedder → 无当前维度可比对:unsearchable 报 0、retrievable 报 null。
|
|
563
|
+
assert.equal(data.vectors.unsearchable, 0);
|
|
564
|
+
assert.equal(data.vectors.retrievable, null);
|
|
565
|
+
const dims = Object.fromEntries(data.vectors.byDimension.map((e) => [e.dimension, e.count]));
|
|
566
|
+
assert.equal(dims["3"], 2);
|
|
567
|
+
assert.equal(dims["4"], 1);
|
|
568
|
+
assert.equal(dims[null], 1, "unparseable vector counted under null");
|
|
569
|
+
const models = Object.fromEntries(data.vectors.byModel.map((e) => [e.model, e.count]));
|
|
570
|
+
assert.equal(models["m1#aaa"], 1);
|
|
571
|
+
assert.equal(models["m2#bbb"], 1);
|
|
572
|
+
assert.equal(models[null], 2, "rows without provenance counted under null");
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("GET /api/dsh-mneme/semantic counts stale-dimension rows as unsearchable with a matching embedder", async () => {
|
|
576
|
+
// 核心回归锁:库里掺一条错维向量,端点必须报出 unsearchable ≥ 1。
|
|
577
|
+
// 旧实现无论怎么混都只给 coverage,状态卡说不出「有向量但检索不到」。
|
|
578
|
+
const { routes, store, service } = setup({ name: "OpenAI", embed() {}, dimension: 3 });
|
|
579
|
+
const a = service.saveWithDedupe({ type: "preference", title: "同维", content: "x" }).memory;
|
|
580
|
+
const b = service.saveWithDedupe({ type: "preference", title: "错维", content: "x" }).memory;
|
|
581
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3]), a.id);
|
|
582
|
+
store.db.prepare("UPDATE memories SET embedding = ? WHERE id = ?").run(JSON.stringify([1, 2, 3, 4, 5, 6, 7, 8]), b.id);
|
|
583
|
+
const sem = routes.find((r) => r.path === "/api/dsh-mneme/semantic");
|
|
584
|
+
const res = new FakeRes();
|
|
585
|
+
await sem.handler(req("/api/dsh-mneme/semantic"), res);
|
|
586
|
+
assert.equal(res.statusCode, 200);
|
|
587
|
+
const data = JSON.parse(res.body);
|
|
588
|
+
assert.equal(data.vectors.unsearchable, 1, "the 8-dim row is not searchable against a 3-dim embedder");
|
|
589
|
+
assert.equal(data.vectors.retrievable, 1, "the 3-dim row is searchable");
|
|
590
|
+
// coverage 走 semantic.vectorIndex.getStats(本测试未挂 → 0),所以「有向量的行」
|
|
591
|
+
// 用 byDimension 总和对照:两条都在「有向量」里,但只有一条可检索 —— 这正是
|
|
592
|
+
// unsearchable 必须存在的原因。
|
|
593
|
+
const embeddedRows = data.vectors.byDimension.reduce((s, e) => s + e.count, 0);
|
|
594
|
+
assert.equal(embeddedRows, 2, "both rows have vectors");
|
|
595
|
+
assert.equal(data.vectors.unsearchable, 1, "yet only one is retrievable");
|
|
596
|
+
});
|
|
597
|
+
|
|
541
598
|
// --- Bug8: llm-audit API (pagination + stats) --------------------------------
|
|
542
599
|
|
|
543
600
|
test("GET /api/dsh-mneme/semantic/llm-audit returns paginated rows", async () => {
|
|
@@ -682,9 +739,15 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
682
739
|
// 溯源:开关与保留期都在嵌套对象里,两个都进 NESTED_FLAG_PATHS))
|
|
683
740
|
// v0.9.0 新增 actionPipelineEnabled(M.O. beta 门控;顶层扁平键,
|
|
684
741
|
// 走 configFlagValue 默认分支,不需要 NESTED_FLAG_PATHS 条目)。
|
|
742
|
+
// v0.9.0 第二批新增 actionRunEnabled(M.O. 执行记账;同样顶层扁平键)。
|
|
743
|
+
// persona 页新增 personaEnabled(人格注入顶层总闸;顶层扁平键,同上)。
|
|
685
744
|
// 计数锁钉的是「白名单键 ↔ config schema 成对」:schema 加了键而漏了白名单
|
|
686
745
|
// (或反过来)时,effective 会静默少键/多键,面板读不到也报不出来。
|
|
687
|
-
|
|
746
|
+
// issue #471 新增 scopeState.enabled/scopeState.enforce(政策轴作用域级可逆闸门;
|
|
747
|
+
// 两个都是嵌套对象子字段,都进 NESTED_FLAG_PATHS)。
|
|
748
|
+
// 合并 #451(人格页)后的真实计数:98(本批前)+ actionRunEnabled + personaEnabled = 99
|
|
749
|
+
// #471 再 + 2 = 101。
|
|
750
|
+
assert.equal(Object.keys(data.effective).length, 101);
|
|
688
751
|
assert.equal(data.effective.dreamSkipInvalid, true);
|
|
689
752
|
assert.equal(data.effective.allowCrossTypeMerge, false);
|
|
690
753
|
assert.equal(data.effective.dreamMergeGuard, true);
|
|
@@ -692,6 +755,9 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
692
755
|
// issue #380:注入前判定两级开关,默认全关(默认路径零行为变化)
|
|
693
756
|
assert.equal(data.effective["preInjectGate.enabled"], false);
|
|
694
757
|
assert.equal(data.effective["preInjectGate.enforce"], false);
|
|
758
|
+
// issue #471:政策轴作用域级可逆闸门,两级开关默认全关(默认路径零行为变化)
|
|
759
|
+
assert.equal(data.effective["scopeState.enabled"], false);
|
|
760
|
+
assert.equal(data.effective["scopeState.enforce"], false);
|
|
695
761
|
assert.equal(data.effective.dreamMinIntervalMinutes, 0);
|
|
696
762
|
assert.equal(data.effective.autoDreamFailureBackoff, false);
|
|
697
763
|
assert.equal(data.effective.dreamMaxTokens, 131072);
|
|
@@ -721,6 +787,11 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
721
787
|
// v0.9.0:M.O. beta 门控默认关——客户端 GET /features 必须能读到这个键
|
|
722
788
|
// (读不到就静默不注册任何座位),且默认值必须是 false(beta opt-in)。
|
|
723
789
|
assert.equal(data.effective.actionPipelineEnabled, false);
|
|
790
|
+
// v0.9.0 第二批:M.O. 执行记账同样默认关,且**是独立的一个键**——看图与写账
|
|
791
|
+
// 分开,避免「只想看图」的人不知不觉获得热路径上的写入能力。
|
|
792
|
+
assert.equal(data.effective.actionRunEnabled, false);
|
|
793
|
+
// 人格页(beta):注入总闸默认关,effective 必须能读到(同 #254 坑形防线)。
|
|
794
|
+
assert.equal(data.effective.personaEnabled, false);
|
|
724
795
|
// 新增字符串 / URL / 枚举键
|
|
725
796
|
assert.equal(data.effective.localEmbedModel, "Xenova/bge-small-zh-v1.5");
|
|
726
797
|
assert.equal(data.effective.ollamaBaseUrl, "http://localhost:11434");
|
|
@@ -747,6 +818,47 @@ test("GET /api/dsh-mneme/features returns empty overrides and effective config d
|
|
|
747
818
|
assert.equal(data.effective.dreamCandidateMode, "window");
|
|
748
819
|
assert.equal(data.effective.dreamCandidateMax, 0);
|
|
749
820
|
assert.equal(data.effective.dreamCandidateMinSim, 0.85);
|
|
821
|
+
// #481:没有 overrides 时不存在「改了还没生效」的键,必须是空数组而不是缺字段
|
|
822
|
+
// ——面板按数组长度渲染提示条,字段缺失会让它渲染出 undefined.length 这类东西。
|
|
823
|
+
assert.ok(Array.isArray(data.pendingRestart), "pendingRestart must always be an array");
|
|
824
|
+
assert.deepEqual(data.pendingRestart, []);
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("GET /api/dsh-mneme/features reports only the overrides the frozen config has not picked up", async () => {
|
|
828
|
+
const { routes, settings } = setup(undefined, "", FLAGS_CFG);
|
|
829
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
830
|
+
// #481 实测现场:面板显示人格总闸「已开启」,人格段却不注入——因为开关在宿主
|
|
831
|
+
// 启动时合并进 cfg 并冻结,运行中改 kv 只是持久化。pendingRestart 就是把
|
|
832
|
+
// 「显式覆盖 ≠ 冻结值」这个落差报出来,否则用户只能靠反复试错发现。
|
|
833
|
+
settings.setFeatureFlags({
|
|
834
|
+
personaEnabled: true, // 冻结值 false → 待重启
|
|
835
|
+
autoDream: false, // 冻结值 true → 待重启
|
|
836
|
+
"memoryQualityFilter.enabled": false, // 冻结值 true → 待重启
|
|
837
|
+
distillMaxChars: 24000 // 与冻结值相同 → 不该报
|
|
838
|
+
});
|
|
839
|
+
const res = new FakeRes();
|
|
840
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
841
|
+
assert.equal(res.statusCode, 200);
|
|
842
|
+
const data = JSON.parse(res.body);
|
|
843
|
+
// 比的是集合而非顺序(顺序跟白名单分组走,不是契约的一部分)。
|
|
844
|
+
assert.deepEqual([...data.pendingRestart].sort(), ["autoDream", "memoryQualityFilter.enabled", "personaEnabled"]);
|
|
845
|
+
assert.ok(!data.pendingRestart.includes("distillMaxChars"),
|
|
846
|
+
"a key whose override equals the frozen value is not pending");
|
|
847
|
+
// effective 仍然是「运行时取什么值」= overrides 优先,不受本字段影响。
|
|
848
|
+
assert.equal(data.effective.personaEnabled, true);
|
|
849
|
+
});
|
|
850
|
+
|
|
851
|
+
test("GET /api/dsh-mneme/features cannot report pendingRestart without a frozen config", async () => {
|
|
852
|
+
// config 缺席(旧调用方直连、未传 cfg)时无从比较冻结值——返回空数组,不编造。
|
|
853
|
+
const { routes, settings } = setup(undefined);
|
|
854
|
+
settings.setFeatureFlags({ personaEnabled: true });
|
|
855
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/features");
|
|
856
|
+
const res = new FakeRes();
|
|
857
|
+
await route.handler(req("/api/dsh-mneme/features"), res);
|
|
858
|
+
assert.equal(res.statusCode, 200);
|
|
859
|
+
const data = JSON.parse(res.body);
|
|
860
|
+
assert.deepEqual(data.pendingRestart, []);
|
|
861
|
+
assert.equal(data.effective.personaEnabled, true, "effective still reports the override");
|
|
750
862
|
});
|
|
751
863
|
|
|
752
864
|
test("PUT /api/dsh-mneme/features round-trips, overrides effective and persists", async () => {
|
|
@@ -784,6 +896,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
784
896
|
// NESTED_FLAG_PATHS——否则 configFlagValue 落到顶层同名键上,effective 静默缺键)。
|
|
785
897
|
"distillRecords.enabled": true,
|
|
786
898
|
"distillRecords.retentionDays": 30,
|
|
899
|
+
// #471:政策轴的两个点号键往返——白名单与 NESTED_FLAG_PATHS 成对缺一即静默缺键。
|
|
900
|
+
"scopeState.enabled": true,
|
|
901
|
+
"scopeState.enforce": true,
|
|
787
902
|
// #164 A2:顶层扁平键(不是对象子字段),与上面两个点号键走同一条往返;
|
|
788
903
|
// 它的默认值断言在计数锁那一条里,这里钉的是「PUT 存进去、effective 读得回」。
|
|
789
904
|
sensitiveScanEnabled: true,
|
|
@@ -805,6 +920,8 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
805
920
|
"preInjectGate.enforce": true,
|
|
806
921
|
"distillRecords.enabled": true,
|
|
807
922
|
"distillRecords.retentionDays": 30,
|
|
923
|
+
"scopeState.enabled": true,
|
|
924
|
+
"scopeState.enforce": true,
|
|
808
925
|
sensitiveScanEnabled: true,
|
|
809
926
|
embedProvider: "local",
|
|
810
927
|
ollamaBaseUrl: "http://127.0.0.1:11434",
|
|
@@ -821,6 +938,9 @@ test("PUT /api/dsh-mneme/features round-trips nested, string, url and enum keys"
|
|
|
821
938
|
assert.equal(data.effective["preInjectGate.enforce"], true);
|
|
822
939
|
assert.equal(data.effective["distillRecords.enabled"], true);
|
|
823
940
|
assert.equal(data.effective["distillRecords.retentionDays"], 30, "整数点号键也要读得回来");
|
|
941
|
+
// #471:政策轴两个点号键同款往返
|
|
942
|
+
assert.equal(data.effective["scopeState.enabled"], true);
|
|
943
|
+
assert.equal(data.effective["scopeState.enforce"], true);
|
|
824
944
|
assert.equal(data.effective.sensitiveScanEnabled, true);
|
|
825
945
|
assert.equal(data.effective.embedProvider, "local");
|
|
826
946
|
assert.equal(data.effective.ollamaBaseUrl, "http://127.0.0.1:11434");
|
|
@@ -1863,3 +1983,92 @@ test("GET /api/dsh-mneme/inject-preview: returns last assembly snapshot (issue #
|
|
|
1863
1983
|
assert.equal(res.statusCode, 404);
|
|
1864
1984
|
injector(); // 快照是模块全局:用完即清,不污染后续用例
|
|
1865
1985
|
});
|
|
1986
|
+
|
|
1987
|
+
// --- /pipeline:观察旁挂能真的挂进投影(CodeRabbit #458 F1 的锁)--------------
|
|
1988
|
+
|
|
1989
|
+
test("GET /api/dsh-mneme/pipeline 带观察旁挂:observationsById 真的进了投影而不是被 catch 吞掉", async () => {
|
|
1990
|
+
// 锁 F1:ids 原本声明在账本分支的块级作用域里,旁挂块一引用就 ReferenceError,
|
|
1991
|
+
// 被 catch 静默吞成「没有观察」——面板上一切正常,fix-step-tool 提议却永远少一类。
|
|
1992
|
+
const { mkdtempSync, mkdirSync, writeFileSync, rmSync } = await import("node:fs");
|
|
1993
|
+
const { tmpdir } = await import("node:os");
|
|
1994
|
+
const { join } = await import("node:path");
|
|
1995
|
+
const { saveObservations } = await import("../src/pipeline-observations.js");
|
|
1996
|
+
|
|
1997
|
+
const root = mkdtempSync(join(tmpdir(), "mo-api-f1-"));
|
|
1998
|
+
try {
|
|
1999
|
+
const project = join(root, "proj");
|
|
2000
|
+
const pipeline = {
|
|
2001
|
+
id: "demo-f1",
|
|
2002
|
+
name: "演示F1",
|
|
2003
|
+
project: "someone/repo",
|
|
2004
|
+
version: 0,
|
|
2005
|
+
status: "active",
|
|
2006
|
+
done: "做完",
|
|
2007
|
+
trigger: [{ kind: "phrase", value: "跑一下" }],
|
|
2008
|
+
steps: [{
|
|
2009
|
+
id: "S1",
|
|
2010
|
+
phase: "做",
|
|
2011
|
+
title: "跑测试",
|
|
2012
|
+
when: "有人要跑",
|
|
2013
|
+
do: "npm test",
|
|
2014
|
+
who: "main",
|
|
2015
|
+
tool: "npm test",
|
|
2016
|
+
done: "测试全绿",
|
|
2017
|
+
risk: "R0",
|
|
2018
|
+
needs: [],
|
|
2019
|
+
why: "没有它后面没得跑",
|
|
2020
|
+
evidence: ["跑过"]
|
|
2021
|
+
}]
|
|
2022
|
+
};
|
|
2023
|
+
// 项目层工作流(<project>/.mneme/pipelines/*.json)
|
|
2024
|
+
mkdirSync(join(project, ".mneme", "pipelines"), { recursive: true });
|
|
2025
|
+
writeFileSync(join(project, ".mneme", "pipelines", "demo-f1.json"), JSON.stringify(pipeline));
|
|
2026
|
+
// 运行账:<project>/.mneme/pipelines/<id>.runs.jsonl(有账才走到旁挂分支)。
|
|
2027
|
+
// 行形状走 normalizeRun 契约:run.at/by + step.at 全必填(时间不补默认)。
|
|
2028
|
+
writeFileSync(
|
|
2029
|
+
join(project, ".mneme", "pipelines", "demo-f1.runs.jsonl"),
|
|
2030
|
+
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"
|
|
2031
|
+
);
|
|
2032
|
+
// 观察旁挂:<memoryDir>/observations/<项目键>/demo-f1.jsonl。项目键走 candidates.js
|
|
2033
|
+
// 的 projectKey(projectRoot)(sha256 前 8 位 + 清洗名),与 api.js 同源——不能用
|
|
2034
|
+
// 裸 observations/ 目录,那不是 api 侧会查的桶。
|
|
2035
|
+
const { projectKey } = await import("../src/candidates.js");
|
|
2036
|
+
const memoryDir = join(root, "memory");
|
|
2037
|
+
const odir = join(memoryDir, "observations", projectKey(project));
|
|
2038
|
+
const saved = saveObservations({
|
|
2039
|
+
dir: odir,
|
|
2040
|
+
pipelineId: "demo-f1",
|
|
2041
|
+
confirmAt: "2026-10-17T02:00:00.000Z",
|
|
2042
|
+
activities: [{ key: "git push", count: 2 }]
|
|
2043
|
+
});
|
|
2044
|
+
assert.equal(saved.ok, true);
|
|
2045
|
+
|
|
2046
|
+
const { routes } = setup(undefined, "", { memoryDir });
|
|
2047
|
+
const route = routes.find((r) => r.path === "/api/dsh-mneme/pipeline");
|
|
2048
|
+
const res = new FakeRes();
|
|
2049
|
+
await route.handler(req(`/api/dsh-mneme/pipeline?id=demo-f1&project=${encodeURIComponent(project)}`), res);
|
|
2050
|
+
assert.equal(res.statusCode, 200);
|
|
2051
|
+
const data = JSON.parse(res.body);
|
|
2052
|
+
const row = (data.available || []).find((r) => r.id === "demo-f1");
|
|
2053
|
+
assert.ok(row, "工作流要出现在清单里");
|
|
2054
|
+
assert.ok(data.pipeline, "选中投影要存在");
|
|
2055
|
+
// F1 的直接断言:观察旁挂真的喂进了 suggestions 的判据(修复前 observationsById
|
|
2056
|
+
// 恒为 {}——ReferenceError 被 catch 吞掉,观察永远空)。
|
|
2057
|
+
// 观察里只写了 git push,而 S1 声明 tool=npm test 且账本确认走过 1 次 → 矛盾
|
|
2058
|
+
// 成立,fix-step-tool 必须冒头(下方正向锁);观察为空时它不该冒,那是
|
|
2059
|
+
// pipeline.test.js 的判据锁。
|
|
2060
|
+
// runsState 是账本健康度(RUNS_STATES: absent/empty/ok/partial/broken),
|
|
2061
|
+
// 不是节点状态——账本一行合法 run 即为 ok。节点级 trusted 由 deriveState 管,
|
|
2062
|
+
// 在 summarizePipeline 的 perNode 里,这里不重复锁。
|
|
2063
|
+
assert.equal(row.runsState, "ok", "账本一行合法 run,健康度应是 ok");
|
|
2064
|
+
// suggestions 在投影顶层(不在 pipeline 对象下)。
|
|
2065
|
+
assert.ok(Array.isArray(data.suggestions), "投影必须带 suggestions 数组");
|
|
2066
|
+
// 正向锁:观察里只有 git push、S1 声明 npm test 且确认走过 → fix-step-tool 必须冒头。
|
|
2067
|
+
// F1 未修时 observationsById 恒为空,这条断言必挂——正是要抓的回归。
|
|
2068
|
+
const fix = data.suggestions.find((s) => s.kind === "fix-step-tool");
|
|
2069
|
+
assert.ok(fix, "观察进了判据(npm test 未见于观察)→ 必须有 fix-step-tool 提议");
|
|
2070
|
+
assert.equal(fix.stepId, "S1");
|
|
2071
|
+
} finally {
|
|
2072
|
+
rmSync(root, { recursive: true, force: true });
|
|
2073
|
+
}
|
|
2074
|
+
});
|
|
@@ -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("
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
});
|