@modusensus/dsh-mneme 0.8.10 → 0.8.12
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 +5 -5
- package/assets/icon.svg +3 -0
- package/lib/client.js +190 -58
- package/lib/config.js +47 -1
- package/lib/dream/decisions.js +16 -0
- package/lib/dream/sleep.js +3 -0
- package/lib/dream.js +57 -9
- package/lib/entities/extractor.js +4 -1
- package/lib/graph/anchoring.js +180 -0
- package/lib/index.js +5 -0
- package/lib/inject.js +15 -3
- package/lib/lang.js +10 -0
- package/lib/service.js +164 -23
- package/lib/settings.js +24 -2
- package/lib/store.js +155 -11
- package/lib/summarize.js +104 -7
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +12 -7
- package/scripts/sync-test-badge.mjs +9 -7
- package/scripts/test-count-sync.mjs +44 -0
- package/src/config.js +47 -1
- package/src/dream/decisions.js +16 -0
- package/src/dream/sleep.js +3 -0
- package/src/dream.js +57 -9
- package/src/entities/extractor.js +4 -1
- package/src/graph/anchoring.js +180 -0
- package/src/index.js +5 -0
- package/src/inject.js +15 -3
- package/src/lang.js +10 -0
- package/src/service.js +164 -23
- package/src/settings.js +24 -2
- package/src/store.js +155 -11
- package/src/summarize.js +104 -7
- package/test/api.test.js +7 -2
- package/test/baseline-persist.test.js +248 -0
- package/test/client.test.js +245 -45
- package/test/dream.test.js +75 -0
- package/test/graph-anchoring.test.js +185 -0
- package/test/graph-inject-budget.test.js +104 -0
- package/test/graph-passive-confirm.test.js +92 -0
- package/test/graph-weight-evolution.test.js +105 -0
- package/test/scope-inject-soft.test.js +106 -0
- package/test/summarize.test.js +177 -0
- package/test/test-count-sync.test.js +60 -0
|
@@ -0,0 +1,106 @@
|
|
|
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
|
+
|
|
6
|
+
// issue #339 / E7 考卷补齐:A2 软加权(foreign ×0.5 / 命中 ×1.25)此前只作用于
|
|
7
|
+
// searchMemories——E7 实测「explicit 标注 + 软档」的注入集与无标注逐条相同
|
|
8
|
+
// (80/80),主泄露面上软档形同虚设。本文件锁注入通道的软加权语义:
|
|
9
|
+
// - softScope 激活(scopeEnabled + scope 至少一维可解析)→ foreign 行在层内
|
|
10
|
+
// 数值积上 ×0.5,压不过同档自己行;未标注行同列吃 BOOST 不被压制;
|
|
11
|
+
// - flag 关 / scope 两维全空 → 注入序与改动前逐字节一致(锁平价);
|
|
12
|
+
// - strictScope 硬过滤先行,被滤行不会被二次降权;
|
|
13
|
+
// - 加权后的序流入 pin 池选取(pin 拿到的是加权后次序)。
|
|
14
|
+
|
|
15
|
+
function setup(config) {
|
|
16
|
+
const store = createStore(":memory:");
|
|
17
|
+
const service = createService({ store, mirror: null, config });
|
|
18
|
+
return { store, service };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const SCOPE_ME = { agent_scope: "me", workspace_scope: null };
|
|
22
|
+
|
|
23
|
+
test("injectCandidates soft weight demotes explicit foreign rows below same-importance own rows", () => {
|
|
24
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false });
|
|
25
|
+
// 同 importance 档(E7 的同档设计):无软加权时全靠插入序,foreign 在前。
|
|
26
|
+
store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
27
|
+
store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
28
|
+
store.save({ type: "decision", title: "foreign-b", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
29
|
+
|
|
30
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
|
|
31
|
+
const titles = injected.map((m) => m.title);
|
|
32
|
+
// foreign ×0.5 = 2.0 < 自己 ×1.25 = 5.0:自己行第一;两条 foreign 同分,
|
|
33
|
+
// 相对序随 store.list 的同分序(updated_at),不锁。
|
|
34
|
+
assert.equal(titles[0], "mine-a");
|
|
35
|
+
assert.deepEqual(titles.slice(1).sort(), ["foreign-a", "foreign-b"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("injectCandidates soft tier keeps unlabeled rows ranked with matches, not suppressed", () => {
|
|
39
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false });
|
|
40
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 4 });
|
|
41
|
+
store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
42
|
+
|
|
43
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
|
|
44
|
+
// 未标注 = 全局可见,与命中行同列(×1.25)而非被压到 foreign 之后。
|
|
45
|
+
assert.deepEqual(injected.map((m) => m.title), ["global", "foreign"]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("injectCandidates order is byte-identical when scopeEnabled is off or scope is anonymous", () => {
|
|
49
|
+
const seed = (store) => {
|
|
50
|
+
store.save({ type: "decision", title: "foreign-a", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
51
|
+
store.save({ type: "decision", title: "mine-a", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
52
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 3 });
|
|
53
|
+
};
|
|
54
|
+
// flag 关:不传 scope(改动前后必须同序——锁平价)。
|
|
55
|
+
const off = setup({ scopeEnabled: false, strictScope: false });
|
|
56
|
+
seed(off.store);
|
|
57
|
+
// flag 开但身份两维全空:软加权门不激活,也不得改序。
|
|
58
|
+
const anon = setup({ scopeEnabled: true, strictScope: false });
|
|
59
|
+
seed(anon.store);
|
|
60
|
+
|
|
61
|
+
// 平价锁:两种情况的注入序必须一致(比较器与改动前逐字节相同)。
|
|
62
|
+
const baseline = off.service.injectCandidates({ maxItems: 5, threshold: 3 }).map((m) => m.title);
|
|
63
|
+
assert.deepEqual(
|
|
64
|
+
anon.service.injectCandidates({ maxItems: 5, threshold: 3, scope: { agent_scope: null, workspace_scope: null } }).map((m) => m.title),
|
|
65
|
+
baseline
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("strictScope hard filter runs before soft weighting: filtered rows are not double-demoted", () => {
|
|
70
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: true });
|
|
71
|
+
store.save({ type: "decision", title: "foreign", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
72
|
+
store.save({ type: "decision", title: "global", content: "x", importance: 3 });
|
|
73
|
+
|
|
74
|
+
const injected = service.injectCandidates({ maxItems: 5, threshold: 3, scope: SCOPE_ME });
|
|
75
|
+
// 硬墙先删 foreign;剩下的 global(未标注)吃 BOOST 保留——若软加权先跑会把
|
|
76
|
+
// foreign 压到后面再被删,成员虽同但语义不同;此处锁成员与顺序。
|
|
77
|
+
assert.deepEqual(injected.map((m) => m.title), ["global"]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("soft-weighted order feeds the pin pool (pin picks the weighted-first candidates)", () => {
|
|
81
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false, pinnedInjectBudget: 1 });
|
|
82
|
+
// pin 池只收 constraint/preference——两条都用 preference,同档靠软加权分序。
|
|
83
|
+
store.save({ type: "preference", title: "foreign-pin", content: "x", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
84
|
+
store.save({ type: "preference", title: "mine", content: "x", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
85
|
+
|
|
86
|
+
const pinnedStats = {};
|
|
87
|
+
const injected = service.injectCandidates({
|
|
88
|
+
maxItems: 5, threshold: 3, scope: SCOPE_ME, pinnedStats,
|
|
89
|
+
});
|
|
90
|
+
// 加权后 mine 在前:pin 池取的是加权后次序的第一条。
|
|
91
|
+
assert.equal(pinnedStats.shown, 1);
|
|
92
|
+
assert.equal(injected[0].title, "mine");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("first-round BM25 fallback applies the soft weight (no query vector yet)", () => {
|
|
96
|
+
const { store, service } = setup({ scopeEnabled: true, strictScope: false });
|
|
97
|
+
// 同内容同长度(标题各 1 token 且不在查询词表)→ BM25 原始分相同;
|
|
98
|
+
// 未加权时同分序随 store.list(updated desc,foreign 后存先出)。
|
|
99
|
+
store.save({ type: "decision", title: "alpha", content: "plan the dinner party menu with care", importance: 4, agent_scope: "me", agent_scope_source: "explicit" });
|
|
100
|
+
store.save({ type: "decision", title: "bravo", content: "plan the dinner party menu with care", importance: 4, agent_scope: "other", agent_scope_source: "explicit" });
|
|
101
|
+
|
|
102
|
+
const injected = service.injectCandidates({
|
|
103
|
+
query: "dinner party menu", maxItems: 5, threshold: 3, scope: SCOPE_ME,
|
|
104
|
+
});
|
|
105
|
+
assert.equal(injected[0].title, "alpha", "BM25 hits are soft-weighted before the semantic-first merge");
|
|
106
|
+
});
|
package/test/summarize.test.js
CHANGED
|
@@ -110,6 +110,25 @@ function toolResultEvent(text, isError, seq) {
|
|
|
110
110
|
};
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// DSH 0.2.x 内核对工具结果的实际投递形状:内核给的是 `role:"tool"` 消息本身,
|
|
114
|
+
// content 里只有 `type:"text"` 块、isError 挂在消息上,不再有 tool-result 块。
|
|
115
|
+
// 蒸馏曾只认旧块形状,于是真实会话里的工具输出与工具报错全部被静默丢弃。
|
|
116
|
+
function toolRoleResultEvent(text, isError, seq) {
|
|
117
|
+
return {
|
|
118
|
+
seq,
|
|
119
|
+
type: "tool/result",
|
|
120
|
+
data: {
|
|
121
|
+
message: {
|
|
122
|
+
role: "tool",
|
|
123
|
+
source: { kind: "tool", callId: `call-${seq}` },
|
|
124
|
+
toolCallId: `call-${seq}`,
|
|
125
|
+
content: [{ type: "text", text }],
|
|
126
|
+
isError
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
113
132
|
function codeDispatchEvent(text, isError, seq) {
|
|
114
133
|
return {
|
|
115
134
|
seq,
|
|
@@ -143,6 +162,32 @@ test("parseSummaryJson extracts valid entries and skips malformed ones", () => {
|
|
|
143
162
|
assert.equal(parsed[1].type, "preference");
|
|
144
163
|
});
|
|
145
164
|
|
|
165
|
+
// --- issue #339 / E8 考卷:JSON 崩溃窗口的 salvage(失败路径修复,默认开)-----
|
|
166
|
+
// E8 实测 10-20% 的窗口输出了含约束的完整对象,只因中段一处语法错误被整窗
|
|
167
|
+
// JSON.parse 拒收 → 生产行为 = 游标卡死 + 温度 0 重试同错 = 静默丢失。
|
|
168
|
+
|
|
169
|
+
test("parseSummaryJson salvages complete objects from a malformed array (issue #339)", () => {
|
|
170
|
+
// E8 现场同款:闭合数组、中段对象含杂散引号(stray quote)。
|
|
171
|
+
const raw = `[
|
|
172
|
+
{"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
|
|
173
|
+
{ " "type": "history", "title": "坏对象", "content": "语法错误" },
|
|
174
|
+
{"type":"preference","title":"硬件","content":"只要 brushed brass"}
|
|
175
|
+
]`;
|
|
176
|
+
const parsed = parseSummaryJson(raw);
|
|
177
|
+
assert.equal(parsed.length, 2, "the broken middle object is dropped, both survivors kept");
|
|
178
|
+
assert.equal(parsed[0].content, "总额 $12,400");
|
|
179
|
+
assert.equal(parsed[1].title, "硬件");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("parseSummaryJson salvage finding nothing stays a failure (no fake empty success)", () => {
|
|
183
|
+
// 只有一个语法坏掉的对象 → 救回 0 条:不得按「显式空数组」放行消费窗口。
|
|
184
|
+
const raw = `[
|
|
185
|
+
{ " "type": "history", "title": "坏", "content": "语法错误" }
|
|
186
|
+
]`;
|
|
187
|
+
const parsed = parseSummaryJson(raw);
|
|
188
|
+
assert.deepEqual(parsed, []);
|
|
189
|
+
});
|
|
190
|
+
|
|
146
191
|
test("subscribes to session/event when autoSummarize enabled", () => {
|
|
147
192
|
const { events } = setup();
|
|
148
193
|
assert.ok(events.some((e) => e.name === "session/event"));
|
|
@@ -673,6 +718,78 @@ test("invalid summary JSON leaves the seq window retryable", async () => {
|
|
|
673
718
|
assert.equal(store.count(), 0, "the valid empty retry remains a no-op");
|
|
674
719
|
});
|
|
675
720
|
|
|
721
|
+
test("a JSON-crashed window with salvageable objects is recovered, consumed once, and audited (issue #339)", async (t) => {
|
|
722
|
+
const malformed = `[
|
|
723
|
+
{"type":"constraint","title":"预算","content":"总额 $12,400","importance":4},
|
|
724
|
+
{ " "type": "history", "title": "坏对象", "content": "语法错误" },
|
|
725
|
+
{"type":"preference","title":"硬件","content":"只要 brushed brass"}
|
|
726
|
+
]`;
|
|
727
|
+
const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
728
|
+
stream() {
|
|
729
|
+
return (async function* () {
|
|
730
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
731
|
+
yield { type: "text-delta", delta: malformed };
|
|
732
|
+
yield { type: "finish", kind: "ok" };
|
|
733
|
+
})();
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
t.after(() => { summarizer.dispose(); store.close(); });
|
|
737
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
738
|
+
const session = {
|
|
739
|
+
id: "s-salvage",
|
|
740
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
741
|
+
events: [userMessage("带语法错误的蒸馏窗口", 1), { seq: 2, type: "turn/end" }]
|
|
742
|
+
};
|
|
743
|
+
|
|
744
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
745
|
+
assert.equal(service.count(), 2, "the two intact objects are written, the broken one dropped");
|
|
746
|
+
const titles = service.list().map((m) => m.title).sort();
|
|
747
|
+
assert.deepEqual(titles, ["硬件", "预算"]);
|
|
748
|
+
assert.equal(calls.length, 1, "salvage consumed the window: no same-window retry");
|
|
749
|
+
const audit = service.listLlmAudits().find((r) => r.status === "success");
|
|
750
|
+
assert.ok(audit, "salvaged run is audited as a success");
|
|
751
|
+
assert.equal(audit.metadata?.json_salvaged, true, "salvage is observable in the audit trail");
|
|
752
|
+
|
|
753
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
754
|
+
assert.equal(calls.length, 1, "the consumed window is not re-distilled");
|
|
755
|
+
assert.equal(service.count(), 2, "no duplicate writes on the second turn/end");
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test("a JSON-crashed window with nothing salvageable stays retryable (issue #339)", async (t) => {
|
|
759
|
+
const hopeless = `[
|
|
760
|
+
{ " "type": "history", "title": "坏", "content": "语法错误" }
|
|
761
|
+
]`;
|
|
762
|
+
let attempt = 0;
|
|
763
|
+
const { events, service, store, calls, summarizer } = setup({ distillRateLimitIntervalMs: 0 }, {
|
|
764
|
+
stream() {
|
|
765
|
+
const output = attempt++ === 0 ? hopeless : `[
|
|
766
|
+
{"type":"history","title":"重试成功","content":"干净窗口","importance":3}
|
|
767
|
+
]`;
|
|
768
|
+
return (async function* () {
|
|
769
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
770
|
+
yield { type: "text-delta", delta: output };
|
|
771
|
+
yield { type: "finish", kind: "ok" };
|
|
772
|
+
})();
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
t.after(() => { summarizer.dispose(); store.close(); });
|
|
776
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
777
|
+
const session = {
|
|
778
|
+
id: "s-salvage-none",
|
|
779
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
780
|
+
events: [userMessage("救不回的窗口", 1), { seq: 2, type: "turn/end" }]
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
784
|
+
assert.equal(service.count(), 0, "zero salvage is not a fake success");
|
|
785
|
+
await handler(session, { seq: 2, type: "turn/end" });
|
|
786
|
+
assert.equal(calls.length, 2, "the window stays retryable");
|
|
787
|
+
assert.equal(service.count(), 1);
|
|
788
|
+
assert.ok(service.listLlmAudits().some(
|
|
789
|
+
(r) => r.status === "error" && r.error_message === "invalid summary JSON"
|
|
790
|
+
), "zero-salvage run keeps the error audit");
|
|
791
|
+
});
|
|
792
|
+
|
|
676
793
|
test("an all-invalid summary leaves the same window retryable until valid memories are saved", async (t) => {
|
|
677
794
|
for (const invalid of [
|
|
678
795
|
[{}],
|
|
@@ -960,6 +1077,47 @@ test("collects real tool result and code dispatch payloads with failure status",
|
|
|
960
1077
|
assert.match(transcript, /代码执行(成功):代码执行结果/);
|
|
961
1078
|
});
|
|
962
1079
|
|
|
1080
|
+
test("collects tool results delivered as role:tool messages, keeping the legacy block shape", async () => {
|
|
1081
|
+
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
1082
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
1083
|
+
const session = {
|
|
1084
|
+
id: "s-tool-role-shape",
|
|
1085
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
1086
|
+
events: [
|
|
1087
|
+
userMessage("检查工具结果两种形状", 1),
|
|
1088
|
+
toolResultEvent("旧形状载荷", false, 2),
|
|
1089
|
+
toolRoleResultEvent("新形状载荷", false, 3),
|
|
1090
|
+
toolRoleResultEvent("TypeError: tool crashed", true, 4),
|
|
1091
|
+
{ seq: 5, type: "turn/end" }
|
|
1092
|
+
]
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
await handler(session, { seq: 5, type: "turn/end" });
|
|
1096
|
+
const transcript = calls[0].messages.find((message) => message.role === "user").content[0].text;
|
|
1097
|
+
assert.match(transcript, /工具结果(成功):旧形状载荷/);
|
|
1098
|
+
assert.match(transcript, /工具结果(成功):新形状载荷/);
|
|
1099
|
+
assert.match(transcript, /工具结果(失败):TypeError: tool crashed/);
|
|
1100
|
+
});
|
|
1101
|
+
|
|
1102
|
+
test("keeps prefix trimming for tool results delivered as role:tool messages", async () => {
|
|
1103
|
+
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
1104
|
+
const handler = events.find((e) => e.name === "session/event").fn;
|
|
1105
|
+
const session = {
|
|
1106
|
+
id: "s-tool-role-trim",
|
|
1107
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
1108
|
+
events: [
|
|
1109
|
+
userMessage("检查新形状截断", 1),
|
|
1110
|
+
toolRoleResultEvent(`OUT_START ${"c".repeat(550)} OUT_END`, false, 2),
|
|
1111
|
+
{ seq: 3, type: "turn/end" }
|
|
1112
|
+
]
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
await handler(session, { seq: 3, type: "turn/end" });
|
|
1116
|
+
const transcript = JSON.stringify(calls[0].messages);
|
|
1117
|
+
assert.ok(transcript.includes("OUT_START"));
|
|
1118
|
+
assert.ok(!transcript.includes("OUT_END"), "role:tool 结果同样走 500 字符前缀截断");
|
|
1119
|
+
});
|
|
1120
|
+
|
|
963
1121
|
test("collects subagent delivery once across inbox and user-message views", async () => {
|
|
964
1122
|
const { events, calls } = setup({ distillRateLimitIntervalMs: 0 });
|
|
965
1123
|
const handler = events.find((e) => e.name === "session/event").fn;
|
|
@@ -1286,3 +1444,22 @@ test("issue#127: a dedupe lookup that yields nothing still lands the entry (neve
|
|
|
1286
1444
|
assert.equal(store.count(), 1, "the entry is stored through the normal write path");
|
|
1287
1445
|
assert.equal(store.all()[0].title, "必须落库");
|
|
1288
1446
|
});
|
|
1447
|
+
|
|
1448
|
+
test("parseSummaryJson salvage only takes direct children of the outer array (issue #339 review)", () => {
|
|
1449
|
+
// 嵌套子数组里的对象不捞(模型没把它当顶层记忆产出),坏对象照旧丢弃。
|
|
1450
|
+
const nested = `[[{"type":"history","title":"nested","content":"kept"}], { " "type":"history"}]`;
|
|
1451
|
+
assert.deepEqual(parseSummaryJson(nested), []);
|
|
1452
|
+
});
|
|
1453
|
+
|
|
1454
|
+
test("parseSummaryJson salvage does not turn string-embedded objects into memories (issue #339 review)", () => {
|
|
1455
|
+
// 全数组解析失败(第二对象坏)+ 第一对象的 content 字符串里嵌着假记忆形状:
|
|
1456
|
+
// 盲扫不得把属性值对象当独立记忆——外层对象整体才是直接子项。
|
|
1457
|
+
const raw = `[
|
|
1458
|
+
{"type":"history","title":"good","content":"note { \\"type\\": \\"history\\", \\"title\\": \\"fake\\" }"},
|
|
1459
|
+
{ " "type": "history", "title": "坏", "content": "x" }
|
|
1460
|
+
]`;
|
|
1461
|
+
const parsed = parseSummaryJson(raw);
|
|
1462
|
+
assert.equal(parsed.length, 1);
|
|
1463
|
+
assert.equal(parsed[0].title, "good");
|
|
1464
|
+
assert.match(parsed[0].content, /fake/);
|
|
1465
|
+
});
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { applyTestCount, readTestCounts } from "../scripts/test-count-sync.mjs";
|
|
7
|
+
|
|
8
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const PKG_ROOT = join(HERE, "..");
|
|
10
|
+
const REPO_ROOT = join(PKG_ROOT, "..");
|
|
11
|
+
const READMES = [join(REPO_ROOT, "README.md"), join(PKG_ROOT, "README.md")];
|
|
12
|
+
|
|
13
|
+
// 双 README 是发布门面,同一个数字在里面重复出现三到五次(一个 shields.io 徽章 +
|
|
14
|
+
// 每条开发命令注释一处,root 是双语所以中英各一条)。它们必须永远同数——否则
|
|
15
|
+
// 「这个项目有多少测试」取决于读者恰好看了哪一行。
|
|
16
|
+
//
|
|
17
|
+
// v0.8.11 实际漂移过:徽章 1437、四条注释留在 1431。本用例就是那次漂移的回归锁,
|
|
18
|
+
// 跑在真实 README 上(不是 fixture),所以它能在发版前拦住同类漂移。
|
|
19
|
+
test("双 README 的测试数处处一致(徽章与所有注释同数)", () => {
|
|
20
|
+
for (const f of READMES) {
|
|
21
|
+
const counts = readTestCounts(readFileSync(f, "utf8"));
|
|
22
|
+
const shapes = new Set(counts.map((c) => c.shape));
|
|
23
|
+
assert.ok(counts.length >= 2, `${f} 抽到的测试数出现处太少:${JSON.stringify(counts)}`);
|
|
24
|
+
assert.ok(shapes.has("badge"), `${f} 没抽到徽章:${JSON.stringify(counts)}`);
|
|
25
|
+
const values = [...new Set(counts.map((c) => c.value))];
|
|
26
|
+
assert.equal(values.length, 1, `${f} 测试数不一致:${JSON.stringify(counts)}`);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// v0.8.11 的真实漂移样本(徽章已 1437、注释仍 1431)。锁住「徽章 + 四种注释形状」
|
|
31
|
+
// 全覆盖:少任何一种,就会有一处静默落后——release-prep.mjs 原先只覆盖徽章 URL,
|
|
32
|
+
// 自动发版路径因此反倒成了漂移来源。
|
|
33
|
+
test("applyTestCount 覆盖徽章与全部四种注释形状(真实漂移样本)", () => {
|
|
34
|
+
const drifted = [
|
|
35
|
+
'<img src="https://img.shields.io/badge/tests-1431%20passed-3E63DD?style=flat-square" alt="tests">',
|
|
36
|
+
"[](https://github.com/slow-stack/mneme)",
|
|
37
|
+
"npm test # 1431 个测试",
|
|
38
|
+
"npm test # 运行 1431 个测试",
|
|
39
|
+
"test/ # 1431 个 node:test 测试(审计与三轴线压测不变量)",
|
|
40
|
+
"npm test # 1431 tests"
|
|
41
|
+
].join("\n");
|
|
42
|
+
const fixed = applyTestCount(drifted, 1437);
|
|
43
|
+
assert.ok(!/\b1431\b/.test(fixed), "仍有 1431 未被替换:\n" + fixed);
|
|
44
|
+
const counts = readTestCounts(fixed);
|
|
45
|
+
assert.equal(counts.length, 6, "六处出现都该被认出来:" + JSON.stringify(counts));
|
|
46
|
+
assert.deepEqual([...new Set(counts.map((c) => c.value))], [1437]);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("applyTestCount 幂等:同一个数重复施加不再产生变化", () => {
|
|
50
|
+
const once = applyTestCount("tests-9%20passed-3E63DD\n# 9 个测试\n# 9 tests", 12);
|
|
51
|
+
assert.equal(applyTestCount(once, 12), once);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// 历史版本表里的「745 测试全绿」「790 通过」是各版本当时的真实记录(sync-test-badge
|
|
55
|
+
// 的注释也写明了刻意不改它们)。替换规则只认徽章 URL 与 `#` 前缀注释两种形状,
|
|
56
|
+
// 这条用例锁住那个边界,免得将来放宽正则把历史改写掉。
|
|
57
|
+
test("applyTestCount 不碰历史版本表里的「N 测试全绿」", () => {
|
|
58
|
+
const history = "| **v0.8.0** | 745 测试全绿 |\n| **v0.7.9** | 790 通过 |\n| v0.2.7 | 263 |";
|
|
59
|
+
assert.equal(applyTestCount(history, 1437), history);
|
|
60
|
+
});
|