@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,481 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createStore } from "../src/store.js";
|
|
4
|
+
import { createService } from "../src/service.js";
|
|
5
|
+
import { createTools } from "../src/tools.js";
|
|
6
|
+
import { runSleep } from "../src/dream/sleep.js";
|
|
7
|
+
import { createDreamScheduler } from "../src/dream.js";
|
|
8
|
+
|
|
9
|
+
// issue #471:作用域级可逆闸门(政策轴)。
|
|
10
|
+
//
|
|
11
|
+
// 与 #17 / #170 那条事实轴(scope 标注与可见性)是两条不同的轴:
|
|
12
|
+
// - 事实轴回答「这条记忆归谁」,答案写在记忆行上,改了它就是在改事实;
|
|
13
|
+
// - 政策轴回答「这一路现在通不通」,答案写在 scope_state 表里,可逆、
|
|
14
|
+
// 不改任何记忆行、不改作用域标注、不动向量与归档位。
|
|
15
|
+
//
|
|
16
|
+
// 三道验收线(对应 issue 正文的三处落点):
|
|
17
|
+
// 1. 默认关:gateByScope 的输出与改动前**逐字段一致**,且是**同一个数组引用**
|
|
18
|
+
// (引用相等才钉得住「零行为变化」,内容相等会让「悄悄复制一份」蒙过去);
|
|
19
|
+
// 2. 按下与松开:同一作用域的候选进不来,松开后原样回来——两个方向都要断言,
|
|
20
|
+
// 只锁「进不来」的话,一个「永远返回空」的实现也能过;
|
|
21
|
+
// 3. 观察档:enabled 开、enforce 关 → 留一条 llm_audit_logs 但注入照常,
|
|
22
|
+
// 这条是两级开关的意义所在(先在真实流量里看「如果按下会少掉多少」)。
|
|
23
|
+
|
|
24
|
+
function setup(config) {
|
|
25
|
+
const store = createStore(":memory:");
|
|
26
|
+
const service = createService({ store, mirror: null, config });
|
|
27
|
+
return { store, service };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PAUSED_CONFIG = { scopeState: { enabled: true, enforce: true } };
|
|
31
|
+
const OBSERVE_CONFIG = { scopeState: { enabled: true, enforce: false } };
|
|
32
|
+
|
|
33
|
+
/** 造一批带 workspace 标注的记忆,一半属于 `proj-a`,一半无标注。 */
|
|
34
|
+
function seed(store) {
|
|
35
|
+
store.save({ type: "decision", title: "a-1", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
36
|
+
store.save({ type: "decision", title: "a-2", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
37
|
+
store.save({ type: "decision", title: "b-1", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-b", workspace_scope_source: "explicit" });
|
|
38
|
+
store.save({ type: "decision", title: "g-1", content: "alpha beta gamma", importance: 4 });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const titles = (rows) => rows.map((m) => m.title).sort();
|
|
42
|
+
|
|
43
|
+
// --- 验收线 1:默认关时逐字段一致,且引用不变 -----------------------------
|
|
44
|
+
|
|
45
|
+
test("默认关:gateByScope 原样返回同一个数组(引用相等,不是等值副本)", () => {
|
|
46
|
+
const { store, service } = setup({});
|
|
47
|
+
seed(store);
|
|
48
|
+
const rows = store.list({});
|
|
49
|
+
const out = service.filterPaused(rows);
|
|
50
|
+
// 引用相等:内容相等的副本会让「复制一份再返回」蒙过去,那样后续每次
|
|
51
|
+
// 检索都白复制一遍,而「零行为变化」这条验收就退化成「看起来一样」。
|
|
52
|
+
assert.equal(out, rows);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("默认关:按下过状态也不生效——开关不打开时状态表根本不被读", () => {
|
|
56
|
+
const { store, service } = setup({});
|
|
57
|
+
seed(store);
|
|
58
|
+
// 即便有人在开关关着的时候写进了状态(比如另一台机器同步过来),也不生效。
|
|
59
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
60
|
+
const rows = store.list({});
|
|
61
|
+
assert.equal(service.filterPaused(rows), rows);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("默认关:注入候选集与无闸门时逐条相同", () => {
|
|
65
|
+
const off = setup({});
|
|
66
|
+
seed(off.store);
|
|
67
|
+
const baseline = titles(off.service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
68
|
+
|
|
69
|
+
const pressed = setup({});
|
|
70
|
+
seed(pressed.store);
|
|
71
|
+
pressed.service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
72
|
+
const after = titles(pressed.service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
73
|
+
|
|
74
|
+
assert.deepEqual(after, baseline);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// --- 验收线 2:按下与松开(两个方向都锁) ---------------------------------
|
|
78
|
+
|
|
79
|
+
test("按下后:被按下作用域的候选进不来注入池,别的照常进", () => {
|
|
80
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
81
|
+
seed(store);
|
|
82
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
83
|
+
|
|
84
|
+
const got = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
85
|
+
assert.deepEqual(got, ["b-1", "g-1"]);
|
|
86
|
+
// 反向断言:不是「全都进不来」。
|
|
87
|
+
assert.ok(got.includes("b-1") && got.includes("g-1"));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("松开后:原样回来(同一个 service 实例,不重开库)", () => {
|
|
91
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
92
|
+
seed(store);
|
|
93
|
+
const before = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
94
|
+
|
|
95
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
96
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["b-1", "g-1"]);
|
|
97
|
+
|
|
98
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active" });
|
|
99
|
+
// 逐条相同(不只「数量相同」):松开要真的恢复原样,不是恢复个大概。
|
|
100
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), before);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("按下不动事实:记忆行、作用域标注、归档位一字段都不变", () => {
|
|
104
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
105
|
+
seed(store);
|
|
106
|
+
const snapshot = () => store.list({}).map((m) => ({
|
|
107
|
+
id: m.id, title: m.title, workspace_scope: m.workspace_scope,
|
|
108
|
+
workspace_scope_source: m.workspace_scope_source, archived: m.archived, forgotten: m.forgotten
|
|
109
|
+
})).sort((x, y) => x.title.localeCompare(y.title));
|
|
110
|
+
|
|
111
|
+
const before = snapshot();
|
|
112
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
113
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active" });
|
|
114
|
+
assert.deepEqual(snapshot(), before);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("检索侧同款:被按下的作用域在 searchMemories 里也出局", async () => {
|
|
118
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
119
|
+
seed(store);
|
|
120
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
121
|
+
|
|
122
|
+
const hits = await service.searchMemories("alpha", { mode: "keyword", limit: 10 });
|
|
123
|
+
const got = titles(hits);
|
|
124
|
+
assert.ok(!got.includes("a-1") && !got.includes("a-2"), `unexpected: ${got.join(",")}`);
|
|
125
|
+
assert.ok(got.includes("b-1"));
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("全局按下('*')拦下该维度所有有标注的行,但不波及未标注行", () => {
|
|
129
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
130
|
+
seed(store);
|
|
131
|
+
service.setScopeState({ dimension: "workspace", scope_value: "*", state: "paused" });
|
|
132
|
+
|
|
133
|
+
// g-1 无 workspace 标注 ⇒ 在 workspace 这一维上不参与比较,不受影响。
|
|
134
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["g-1"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("维度独立:按下 agent 维不影响 workspace 维的行", () => {
|
|
138
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
139
|
+
seed(store);
|
|
140
|
+
service.setScopeState({ dimension: "agent", scope_value: "proj-a", state: "paused" });
|
|
141
|
+
|
|
142
|
+
// 这批行没有 agent 标注,agent 维的按下一条都命中不了。
|
|
143
|
+
const got = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
144
|
+
assert.deepEqual(got, ["a-1", "a-2", "b-1", "g-1"]);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// --- 验收线 3:观察档留审计但不拦 -----------------------------------------
|
|
148
|
+
|
|
149
|
+
test("观察档:注入照常,但留一条 scopeState 审计行", () => {
|
|
150
|
+
const { store, service } = setup(OBSERVE_CONFIG);
|
|
151
|
+
seed(store);
|
|
152
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
153
|
+
|
|
154
|
+
const got = titles(service.injectCandidates({ maxItems: 10, threshold: 1 }));
|
|
155
|
+
// 观察档不拦:四条全在。
|
|
156
|
+
assert.deepEqual(got, ["a-1", "a-2", "b-1", "g-1"]);
|
|
157
|
+
|
|
158
|
+
const audits = store.listLlmAudits({ source: "scopeState" });
|
|
159
|
+
assert.equal(audits.length, 1);
|
|
160
|
+
assert.equal(audits[0].operation_type, "scope_state_gate");
|
|
161
|
+
assert.equal(audits[0].model_id, "-");
|
|
162
|
+
assert.equal(audits[0].status, "skipped");
|
|
163
|
+
assert.equal(audits[0].metadata.enforced, false);
|
|
164
|
+
// gated 记的是「本应拦下几条」,不是「拦了几条」——观察档下后者恒 0,
|
|
165
|
+
// 记它就等于没记。
|
|
166
|
+
assert.equal(audits[0].metadata.gated, 2);
|
|
167
|
+
assert.deepEqual(audits[0].metadata.paused, [{ dimension: "workspace", scope_value: "proj-a" }]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("拦截档:同一条审计行但 enforced=true,且真拦下了", () => {
|
|
171
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
172
|
+
seed(store);
|
|
173
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
174
|
+
|
|
175
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["b-1", "g-1"]);
|
|
176
|
+
|
|
177
|
+
const audits = store.listLlmAudits({ source: "scopeState" });
|
|
178
|
+
assert.equal(audits.length, 1);
|
|
179
|
+
assert.equal(audits[0].metadata.enforced, true);
|
|
180
|
+
assert.equal(audits[0].metadata.gated, 2);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("没有候选被拦时不写审计行(门没经过人,不该留痕)", () => {
|
|
184
|
+
const { store, service } = setup(OBSERVE_CONFIG);
|
|
185
|
+
seed(store);
|
|
186
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-zzz", state: "paused" });
|
|
187
|
+
service.injectCandidates({ maxItems: 10, threshold: 1 });
|
|
188
|
+
assert.equal(store.listLlmAudits({ source: "scopeState" }).length, 0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("enforce 开、enabled 关:无效果(enforce 不单独生效,同 preInjectGate)", () => {
|
|
192
|
+
const { store, service } = setup({ scopeState: { enabled: false, enforce: true } });
|
|
193
|
+
seed(store);
|
|
194
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
195
|
+
assert.deepEqual(titles(service.injectCandidates({ maxItems: 10, threshold: 1 })), ["a-1", "a-2", "b-1", "g-1"]);
|
|
196
|
+
assert.equal(store.listLlmAudits({ source: "scopeState" }).length, 0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// --- 状态表本身的语义 -----------------------------------------------------
|
|
200
|
+
|
|
201
|
+
test("同一状态重复按下不刷新 decided_at(按两次不等于换了决定)", () => {
|
|
202
|
+
const { service } = setup(PAUSED_CONFIG);
|
|
203
|
+
const first = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", decided_at: "2026-01-01T00:00:00.000Z" });
|
|
204
|
+
const again = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", decided_at: "2026-01-02T00:00:00.000Z" });
|
|
205
|
+
assert.equal(first.decided_at, "2026-01-01T00:00:00.000Z");
|
|
206
|
+
assert.equal(again.decided_at, "2026-01-01T00:00:00.000Z");
|
|
207
|
+
|
|
208
|
+
const flipped = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active", decided_at: "2026-01-03T00:00:00.000Z" });
|
|
209
|
+
assert.equal(flipped.decided_at, "2026-01-03T00:00:00.000Z");
|
|
210
|
+
assert.equal(flipped.state, "active");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("不带理由的重按不抹掉已有理由(理由被一次手滑抹掉是净损失)", () => {
|
|
214
|
+
const { service } = setup(PAUSED_CONFIG);
|
|
215
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", note: "等这轮重构完再开" });
|
|
216
|
+
const again = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "active" });
|
|
217
|
+
assert.equal(again.note, "等这轮重构完再开");
|
|
218
|
+
|
|
219
|
+
const replaced = service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused", note: "换了个理由" });
|
|
220
|
+
assert.equal(replaced.note, "换了个理由");
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("非法维度/值/状态返回 undefined,不静默落一行永不生效的状态", () => {
|
|
224
|
+
const { service, store } = setup(PAUSED_CONFIG);
|
|
225
|
+
assert.equal(service.setScopeState({ dimension: "agent", scope_value: "ok", state: "maybe" }), undefined);
|
|
226
|
+
assert.equal(service.setScopeState({ dimension: "nope", scope_value: "ok", state: "paused" }), undefined);
|
|
227
|
+
assert.equal(service.setScopeState({ dimension: "agent", scope_value: " ", state: "paused" }), undefined);
|
|
228
|
+
assert.equal(store.listScopeStates().length, 0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("一次现读:另一个实例改了状态,本实例下一次判定立刻看得见(没有缓存要失效)", () => {
|
|
232
|
+
const store = createStore(":memory:");
|
|
233
|
+
const a = createService({ store, mirror: null, config: PAUSED_CONFIG });
|
|
234
|
+
const b = createService({ store, mirror: null, config: PAUSED_CONFIG });
|
|
235
|
+
store.save({ type: "decision", title: "a-1", content: "alpha beta gamma", importance: 4, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
236
|
+
|
|
237
|
+
assert.deepEqual(titles(a.injectCandidates({ maxItems: 10, threshold: 1 })), ["a-1"]);
|
|
238
|
+
b.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
239
|
+
assert.deepEqual(titles(a.injectCandidates({ maxItems: 10, threshold: 1 })), []);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
// --- 工具面 ---------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
function makeTools(config) {
|
|
245
|
+
const store = createStore(":memory:");
|
|
246
|
+
const service = createService({ store, mirror: null, config });
|
|
247
|
+
const ctx = {
|
|
248
|
+
tools: { register() {} },
|
|
249
|
+
logger: { warn() {}, info() {}, error() {} }
|
|
250
|
+
};
|
|
251
|
+
const tools = createTools(ctx, service, config, null);
|
|
252
|
+
const byName = new Map(tools.map((t) => [t.name, t]));
|
|
253
|
+
return { store, service, tool: byName.get("set_scope_state") };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
test("set_scope_state 回执回显库内当前值,不是回显入参", async () => {
|
|
257
|
+
const { tool } = makeTools(PAUSED_CONFIG);
|
|
258
|
+
const first = await tool.execute({ dimension: "workspace", scope_value: "proj-a", state: "paused", note: "先停一下" });
|
|
259
|
+
assert.deepEqual(first.scope, {
|
|
260
|
+
dimension: "workspace",
|
|
261
|
+
scope_value: "proj-a",
|
|
262
|
+
state: "paused",
|
|
263
|
+
note: "先停一下",
|
|
264
|
+
decided_at: first.scope.decided_at
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// 同一状态重按:入参没带 note,但回执里 note 还在,decided_at 也没变。
|
|
268
|
+
const again = await tool.execute({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
269
|
+
assert.equal(again.scope.decided_at, first.scope.decided_at);
|
|
270
|
+
assert.equal(again.scope.note, "先停一下");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("set_scope_state 对非法参数抛可行动的错,不是静默返回空", async () => {
|
|
274
|
+
const { tool } = makeTools(PAUSED_CONFIG);
|
|
275
|
+
await assert.rejects(() => tool.execute({ dimension: "nope", scope_value: "x", state: "paused" }), /dimension/);
|
|
276
|
+
await assert.rejects(() => tool.execute({ dimension: "agent", scope_value: "x", state: "maybe" }), /state/);
|
|
277
|
+
await assert.rejects(() => tool.execute({ dimension: "agent", scope_value: "", state: "paused" }), /scope_value/);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("检视面刻意不过闸:按下之后 memory_list / memory_get 仍看得见那一行", async () => {
|
|
281
|
+
const config = { ...PAUSED_CONFIG, strictScope: false };
|
|
282
|
+
const store = createStore(":memory:");
|
|
283
|
+
const service = createService({ store, mirror: null, config });
|
|
284
|
+
const ctx = { tools: { register() {} }, logger: { warn() {}, info() {}, error() {} } };
|
|
285
|
+
const tools = new Map(createTools(ctx, service, config, null).map((t) => [t.name, t]));
|
|
286
|
+
|
|
287
|
+
const memory = store.save({ type: "project", title: "hidden", content: "body", importance: 3, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
288
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
289
|
+
|
|
290
|
+
// 供料面:被按下 → 不进注入池。
|
|
291
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 0, scope: { agent_scope: null, workspace_scope: "proj-a" } });
|
|
292
|
+
assert.ok(!injected.some((m) => m.id === memory.id), "供料面必须已挡住");
|
|
293
|
+
|
|
294
|
+
// 检视面:仍看得见。这是刻意的——按下的是政策不是事实,人还要能查
|
|
295
|
+
// 「我按下了什么、里面有什么」,否则就没有入口确认该不该松开。
|
|
296
|
+
// 若将来有人「顺手」把闸门也接到 list/get 上,这条会红。
|
|
297
|
+
const listed = await tools.get("memory_list").execute({});
|
|
298
|
+
assert.ok(listed.items.some((m) => m.id === memory.id), "memory_list 不该被政策轴关掉(检视面)");
|
|
299
|
+
const got = await tools.get("memory_get").execute({ id: memory.id });
|
|
300
|
+
assert.equal(got.memory.id, memory.id, "memory_get 不该被政策轴关掉(检视面)");
|
|
301
|
+
store.close();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// --- 睡眠侧:政策轴不只管「看得见」,还要管「改得到」 -----------------------
|
|
305
|
+
//
|
|
306
|
+
// 上面所有用例都停在读侧(检索出局、注入不进)。但 sleep 的四个相位里有三个会
|
|
307
|
+
// **写**:demotion 会 setArchived / demoteToSummary(真改归档面),patterns 会
|
|
308
|
+
// mint 出 type=pattern 的新行。只挡读侧的话,闸门开着而每 6 小时一次的睡眠照样
|
|
309
|
+
// 在被按下的作用域上动手——「按下」就只等于让它在白天看不见,夜里照旧。
|
|
310
|
+
//
|
|
311
|
+
// 每条都配一个同轮的反向断言(未被按下的同类记忆**确实**被处理了):只断言
|
|
312
|
+
// 「暂停的没动」的话,一个「整个相位都没跑」的实现也能全绿。
|
|
313
|
+
|
|
314
|
+
/** demotion 要用时间窗 + 热联合判定,故把 project 的 λ 调快到冷态可降级。 */
|
|
315
|
+
function sleepConfig(overrides = {}) {
|
|
316
|
+
return {
|
|
317
|
+
...PAUSED_CONFIG,
|
|
318
|
+
sleepModeEnabled: true,
|
|
319
|
+
heatTypeDecay: { project: 0.02 },
|
|
320
|
+
sleepArchiveDays: 30,
|
|
321
|
+
sleepCompressDays: 90,
|
|
322
|
+
sleepPatternMinMemories: 100,
|
|
323
|
+
sleepMaxPatternPerRun: 3,
|
|
324
|
+
...overrides
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function sleepCtx() {
|
|
329
|
+
return {
|
|
330
|
+
logger: { warn() {}, info() {} },
|
|
331
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "sleep-model" }) },
|
|
332
|
+
llm: {
|
|
333
|
+
async *stream() {
|
|
334
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
335
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// 标注走 store.save 的入参——那是存储层唯一的落标注路径,saveWithDedupe 不收
|
|
342
|
+
// 这两个字段(标注是 scope.js 在工具层盖的)。这一点本身也是被测的性质:
|
|
343
|
+
// 政策轴按**行上已有的标注**匹配,与标注从哪条路写进来无关。
|
|
344
|
+
|
|
345
|
+
test("睡眠侧:被按下作用域的冷记忆不被 demotion 降级,同轮的未按下记忆照常降级", async () => {
|
|
346
|
+
const { store, service } = setup(sleepConfig());
|
|
347
|
+
const paused = store.save({
|
|
348
|
+
type: "project", title: "paused-cold", content: "paused-cold 的正文".repeat(20), importance: 3,
|
|
349
|
+
workspace_scope: "proj-a", workspace_scope_source: "explicit"
|
|
350
|
+
});
|
|
351
|
+
const open = store.save({
|
|
352
|
+
type: "project", title: "open-cold", content: "open-cold 的正文".repeat(20), importance: 3
|
|
353
|
+
});
|
|
354
|
+
for (const m of [paused, open]) {
|
|
355
|
+
service.touchLastAccess(m.id, new Date(Date.now() - 40 * 86400000).toISOString());
|
|
356
|
+
}
|
|
357
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
358
|
+
|
|
359
|
+
const ctx = sleepCtx();
|
|
360
|
+
await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
361
|
+
|
|
362
|
+
const afterPaused = service.getById(paused.id);
|
|
363
|
+
const afterOpen = service.getById(open.id);
|
|
364
|
+
assert.equal(afterPaused._full_content ?? null, null, "被按下的那条一个字都不该被改写");
|
|
365
|
+
assert.ok(afterOpen._full_content, "同轮里未按下的冷记忆必须真被降级——否则本用例只是证明了相位没跑");
|
|
366
|
+
store.close();
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("睡眠侧:被按下作用域的冷记忆不被 demotion 归档,同轮的未按下记忆照常归档", async () => {
|
|
370
|
+
const { store, service } = setup(sleepConfig());
|
|
371
|
+
const paused = store.save({
|
|
372
|
+
type: "project", title: "paused-ancient", content: "paused-ancient 的正文", importance: 3,
|
|
373
|
+
agent_scope: "agent-x", agent_scope_source: "explicit"
|
|
374
|
+
});
|
|
375
|
+
const open = store.save({
|
|
376
|
+
type: "project", title: "open-ancient", content: "open-ancient 的正文", importance: 3
|
|
377
|
+
});
|
|
378
|
+
// 推到 compressCut 之外(>= 90 天)→ 直接归档而不是压缩成摘要。
|
|
379
|
+
const ancient = new Date(Date.now() - 100 * 86400000).toISOString();
|
|
380
|
+
for (const m of [paused, open]) service.touchLastAccess(m.id, ancient);
|
|
381
|
+
service.setScopeState({ dimension: "agent", scope_value: "agent-x", state: "paused" });
|
|
382
|
+
|
|
383
|
+
const ctx = sleepCtx();
|
|
384
|
+
await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
385
|
+
|
|
386
|
+
assert.equal(service.getById(paused.id).archived, false, "被按下的作用域不该被睡眠归档");
|
|
387
|
+
assert.equal(service.getById(open.id).archived, true, "未按下的同类必须真被归档");
|
|
388
|
+
store.close();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
test("睡眠侧:被按下作用域的行不进 pattern 扫描池,也就不该占掉名额", async () => {
|
|
392
|
+
const { store, service } = setup(sleepConfig());
|
|
393
|
+
// 顺序是这条用例的关键:池子按 updated_at 降序取前 sleepPatternMinMemories 条,
|
|
394
|
+
// 所以**被按下的必须最后创建**(= 最新),才能在「政策轴排在截断之后」的实现里
|
|
395
|
+
// 抢到名额。第一版把被按下的写在前头,未修复代码取到的恰好是两条 open,
|
|
396
|
+
// 用例照样全绿——那是「没测到东西」,不是「测过了」。
|
|
397
|
+
store.save({ type: "project", title: "open-1", content: "open-1 正文", importance: 3 });
|
|
398
|
+
store.save({ type: "project", title: "open-2", content: "open-2 正文", importance: 3 });
|
|
399
|
+
for (const t of ["p1", "p2"]) {
|
|
400
|
+
store.save({ type: "project", title: t, content: `${t} 正文`, importance: 3, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
401
|
+
}
|
|
402
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
403
|
+
|
|
404
|
+
const seen = [];
|
|
405
|
+
const ctx = {
|
|
406
|
+
...sleepCtx(),
|
|
407
|
+
llm: {
|
|
408
|
+
async *stream(options) {
|
|
409
|
+
const text = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
410
|
+
seen.push(text);
|
|
411
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
412
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
await runSleep(ctx, service, sleepConfig({ sleepPatternMinMemories: 2 }), ctx.logger, null, null);
|
|
417
|
+
|
|
418
|
+
const scanned = seen.join("\n");
|
|
419
|
+
assert.ok(scanned.includes("open-1"), "未按下的记忆必须进扫描池——被按下的不该把它们挤出名额");
|
|
420
|
+
assert.ok(!scanned.includes("p1"), "被按下的记忆不该出现在模式扫描的提示里");
|
|
421
|
+
store.close();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("睡眠侧:被按下作用域的行不进关系补全的候选池", async () => {
|
|
425
|
+
const { store, service } = setup(sleepConfig());
|
|
426
|
+
const alpha = service.createEntity({ name: "Alpha", type: "project" });
|
|
427
|
+
service.createEntity({ name: "Beta", type: "project" });
|
|
428
|
+
store.save({
|
|
429
|
+
type: "project", title: "t", content: "Alpha 与 Beta 一起出现", importance: 3,
|
|
430
|
+
workspace_scope: "proj-a", workspace_scope_source: "explicit"
|
|
431
|
+
});
|
|
432
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
433
|
+
|
|
434
|
+
const ctx = sleepCtx();
|
|
435
|
+
await runSleep(ctx, service, sleepConfig(), ctx.logger, null, null);
|
|
436
|
+
assert.equal((service.getRelations(alpha.id) ?? []).length, 0, "被按下的记忆不该被拿去连关系");
|
|
437
|
+
store.close();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------- dream 侧
|
|
441
|
+
|
|
442
|
+
test("巩固侧:被按下作用域的行不进总览输入——否则会被洗成无作用域行", async () => {
|
|
443
|
+
const { store, service } = setup(PAUSED_CONFIG);
|
|
444
|
+
service.saveWithDedupe({ type: "project", title: "open-title", content: "open 正文", importance: 3 });
|
|
445
|
+
// 标注走 store.save 的入参——saveWithDedupe 不收这两个字段(标注是 scope.js 在工具层盖的)。
|
|
446
|
+
store.save({ type: "project", title: "paused-title", content: "paused 正文", importance: 3, workspace_scope: "proj-a", workspace_scope_source: "explicit" });
|
|
447
|
+
service.setScopeState({ dimension: "workspace", scope_value: "proj-a", state: "paused" });
|
|
448
|
+
|
|
449
|
+
const summaryPrompts = [];
|
|
450
|
+
const ctx = {
|
|
451
|
+
logger: { warn() {} },
|
|
452
|
+
agentDefaultModel: { currentSelection: () => ({ provider: "mock", model: "mock-model" }) },
|
|
453
|
+
llm: {
|
|
454
|
+
async *stream(options) {
|
|
455
|
+
const userText = options.messages.find((m) => m.role === "user")?.content?.[0]?.text ?? "";
|
|
456
|
+
if (userText.startsWith("id=")) {
|
|
457
|
+
yield { type: "text-delta", index: 0, text: "[]" };
|
|
458
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
summaryPrompts.push(userText);
|
|
462
|
+
yield { type: "text-delta", index: 0, text: "总览正文" };
|
|
463
|
+
yield { type: "finish", reason: { kind: "stop" } };
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
const dream = createDreamScheduler({ onRun: () => Promise.resolve({ ok: true }), thresholdCount: 1 });
|
|
468
|
+
await dream.runDream(ctx, service, {});
|
|
469
|
+
|
|
470
|
+
const fed = summaryPrompts.join("\n");
|
|
471
|
+
assert.ok(fed.includes("open-title"), "未按下的记忆必须进总览输入——否则本用例只是证明了总览没跑");
|
|
472
|
+
assert.ok(!fed.includes("paused-title"), "被按下的记忆不该进总览输入");
|
|
473
|
+
|
|
474
|
+
// 这是这条用例真正的要点:总览落库时是**无作用域**的 summary 行,
|
|
475
|
+
// 所以一旦被按下的内容混进去,注入侧按作用域过滤就再也拦不住它——
|
|
476
|
+
// 内容已经被洗成全局行了。别的入口漏了只是「漏一条」,这里漏了是「一直漏」。
|
|
477
|
+
const overview = service.all().find((m) => m.type === "summary" && m.source === "dream");
|
|
478
|
+
assert.ok(overview, "总览仍应正常落库(闸门不该把总览整个关掉)");
|
|
479
|
+
assert.equal(overview.workspace_scope ?? null, null, "总览本身没有作用域标注——正因如此,输入必须在源头过滤");
|
|
480
|
+
store.close();
|
|
481
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// SECURITY.md 的两处声明会随时间变成谎话,而且没人会注意到:
|
|
2
|
+
// 一是「Supported Versions」表——0.9.0 发了两个月它还在写 0.8.x 活跃开发中(#SECURITY 审计实测);
|
|
3
|
+
// 二是文件里的相对链接——docs/DAEMON.md 实际在 dsh-mneme/docs/ 下,链接指错了也没人点。
|
|
4
|
+
// 这里把两件事都锁住:版本行必须等于 package.json 的 major.minor,链接必须真的能 stat 到。
|
|
5
|
+
// 不锁「正文里出现的版本号」——历史审计节记录的是过去的事实,写死版本号在那里是对的。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
9
|
+
import { fileURLToPath } from "node:url";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { createStore } from "../src/store.js";
|
|
12
|
+
import { createService } from "../src/service.js";
|
|
13
|
+
import { createSettings } from "../src/settings.js";
|
|
14
|
+
import { createApi } from "../src/api.js";
|
|
15
|
+
|
|
16
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
17
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
18
|
+
const docPath = path.join(repoRoot, "SECURITY.md");
|
|
19
|
+
const doc = readFileSync(docPath, "utf8");
|
|
20
|
+
|
|
21
|
+
// package.json 的 version → 支持表里应当出现的那一行("0.9.0" → "0.9.x")。
|
|
22
|
+
const { version } = JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
|
|
23
|
+
const [major, minor] = version.split(".");
|
|
24
|
+
const expectedVersionRow = `${major}.${minor}.x`;
|
|
25
|
+
|
|
26
|
+
// 取某一节(到下一个同级 ## 标题为止)。用节而不是全文 grep:
|
|
27
|
+
// 历史审计节里也有 "v0.x.y" 字样,全文匹配会假绿。
|
|
28
|
+
function section(heading) {
|
|
29
|
+
const start = doc.indexOf(heading);
|
|
30
|
+
assert.ok(start >= 0, `SECURITY.md 里应有「${heading}」一节`);
|
|
31
|
+
const rest = doc.slice(start + heading.length);
|
|
32
|
+
const end = rest.search(/\r?\n## /);
|
|
33
|
+
return end >= 0 ? rest.slice(0, end) : rest;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("SECURITY.md 的支持版本表跟着 package.json 走(中英各一份)", () => {
|
|
37
|
+
for (const heading of ["## Supported Versions", "## 支持版本"]) {
|
|
38
|
+
const body = section(heading);
|
|
39
|
+
// 表第一行就是「当前支持」的那一行;锁它,别锁整表(历史版本行本来就该在)。
|
|
40
|
+
const rows = body.split(/\r?\n/).filter((l) => /^\|\s*[\d<]/.test(l));
|
|
41
|
+
assert.ok(rows.length >= 2, `${heading} 的版本表应至少两行,实际 ${rows.length} 行`);
|
|
42
|
+
const current = rows[0].split("|")[1].trim();
|
|
43
|
+
assert.equal(current, expectedVersionRow,
|
|
44
|
+
`${heading} 的首行应写 ${expectedVersionRow}(package.json 为 ${version}),实际写了 ${current}`);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("SECURITY.md 的版本表在两种语言之间是同一张表", () => {
|
|
49
|
+
const versions = (heading) => section(heading)
|
|
50
|
+
.split(/\r?\n/)
|
|
51
|
+
.filter((l) => /^\|\s*[\d<]/.test(l))
|
|
52
|
+
.map((l) => l.split("|")[1].trim());
|
|
53
|
+
assert.deepEqual(versions("## 支持版本"), versions("## Supported Versions"),
|
|
54
|
+
"中文表与英文表的版本行必须逐行一致,否则读者会看到两个不同的支持范围");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("SECURITY.md 里的相对链接都能落到真实文件", () => {
|
|
58
|
+
// 链接从仓库根解析(README 与 SECURITY.md 都在根上)。
|
|
59
|
+
// 只查相对路径:外链、锚点、mailto 不归这里管。
|
|
60
|
+
const links = [...doc.matchAll(/\[[^\]]*\]\(([^)\s]+)\)/g)]
|
|
61
|
+
.map((m) => m[1])
|
|
62
|
+
.filter((href) => !/^(?:[a-z][a-z0-9+.-]*:|#)/i.test(href))
|
|
63
|
+
.map((href) => href.split("#")[0])
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
assert.ok(links.length >= 2, `应解析出至少 2 条相对链接,实际 ${links.length}——正则失配会让这个用例变成永真`);
|
|
66
|
+
const broken = links.filter((href) => !existsSync(path.resolve(repoRoot, href)));
|
|
67
|
+
assert.deepEqual(broken, [], "这些相对链接指向了不存在的文件:" + broken.join(", "));
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("SECURITY.md 不再把供应商写成已停用的那家", () => {
|
|
71
|
+
// 依赖扫描的实况是 gitleaks + dependency-review + OSV-Scanner + Dependabot,
|
|
72
|
+
// 文档里曾长期写着第三方商业扫描平台(该平台并不在 CI 里跑)。
|
|
73
|
+
assert.ok(!/OrbisAI/i.test(doc), "依赖扫描工具应写 CI 里真实在跑的那几个,不能留旧供应商名");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 安全模型节那两张路由清单是「从代码抄出来的」,抄错了没人会发现:
|
|
77
|
+
// 实测就漏过 /persona(token-write,GET 开放)与 /conflicts(token,只读也要 token)。
|
|
78
|
+
// 这里不重跑鉴权逻辑(那是 route-policy.test.js 的事),只锁「文档清单 == 代码里的 policy 声明」:
|
|
79
|
+
// 以真实装配出来的路由为准,双向比对——文档少写、多写、写错边,都红。
|
|
80
|
+
function declaredRoutes() {
|
|
81
|
+
const store = createStore(":memory:");
|
|
82
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
83
|
+
const settings = createSettings(store.db);
|
|
84
|
+
const mounted = [];
|
|
85
|
+
createApi(
|
|
86
|
+
{ webServer: { register(route) { mounted.push(route); return () => {}; } } },
|
|
87
|
+
service, settings, { add() {}, remove() {}, list: () => [] }, null, undefined, "secret-token"
|
|
88
|
+
);
|
|
89
|
+
// 路由名去掉公共前缀,文档里写的是 /export 而不是 /api/dsh-mneme/export。
|
|
90
|
+
return mounted.map((route) => ({
|
|
91
|
+
name: route.path.replace("/api/dsh-mneme", "") || "/",
|
|
92
|
+
policy: route.policy
|
|
93
|
+
}));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 从清单行里抠出反引号包着的路由。只认「可选方法 + 斜杠开头」的形状,
|
|
97
|
+
// 这样 `GET`/`HEAD` 这种纯方法名不会被当成路径。
|
|
98
|
+
function routesInBullet(line) {
|
|
99
|
+
const out = [];
|
|
100
|
+
for (const m of line.matchAll(/`([^`]+)`/g)) {
|
|
101
|
+
const hit = m[1].trim().match(/^(?:GET|HEAD|POST|PUT|PATCH|DELETE)?\s*(\/\S*)$/);
|
|
102
|
+
if (hit) out.push(hit[1]);
|
|
103
|
+
}
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function bullet(prefix) {
|
|
108
|
+
const line = doc.split(/\r?\n/).find((l) => l.startsWith(prefix));
|
|
109
|
+
assert.ok(line, `SECURITY.md 里应有以「${prefix}」开头的清单行`);
|
|
110
|
+
return routesInBullet(line);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 文档里的 /semantic/graph/* 覆盖代码里的 /semantic/graph/ego 与 /semantic/graph/entity-attrs。
|
|
114
|
+
function covers(docEntry, name) {
|
|
115
|
+
if (docEntry === name) return true;
|
|
116
|
+
return docEntry.endsWith("/*") && name.startsWith(docEntry.slice(0, -1));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
test("SECURITY.md 的开放读清单与代码里的 policy 声明一致", () => {
|
|
120
|
+
const open = declaredRoutes().filter((r) => r.policy === "open" || r.policy === "token-write");
|
|
121
|
+
for (const prefix of ["- **Open reads", "- **开放读"]) {
|
|
122
|
+
const listed = bullet(prefix);
|
|
123
|
+
const missing = open.filter((r) => !listed.some((e) => covers(e, r.name))).map((r) => r.name);
|
|
124
|
+
assert.deepEqual(missing, [], `${prefix} 漏了这些实际开放的路由:${missing.join(", ")}`);
|
|
125
|
+
const stale = listed.filter((e) => !open.some((r) => covers(e, r.name)));
|
|
126
|
+
assert.deepEqual(stale, [], `${prefix} 写了代码里并不开放的路由:${stale.join(", ")}`);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("SECURITY.md 的受门控清单与代码里的 policy 声明一致", () => {
|
|
131
|
+
const gated = declaredRoutes().filter((r) => r.policy === "token" || r.policy === "token-write");
|
|
132
|
+
for (const prefix of ["- **Gated routes", "- **受门控的路由"]) {
|
|
133
|
+
const listed = bullet(prefix);
|
|
134
|
+
const missing = gated.filter((r) => !listed.some((e) => covers(e, r.name))).map((r) => r.name);
|
|
135
|
+
assert.deepEqual(missing, [], `${prefix} 漏了这些实际受门控的路由:${missing.join(", ")}`);
|
|
136
|
+
const stale = listed.filter((e) => !gated.some((r) => covers(e, r.name)));
|
|
137
|
+
assert.deepEqual(stale, [], `${prefix} 写了代码里并不受门控的路由:${stale.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
});
|