@modusensus/dsh-mneme 0.8.16 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +91 -16
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +27 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/SEMANTIC.md +3 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +403 -62
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2294 -423
- package/lib/config.js +63 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/embedding.js +116 -15
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +81 -12
- package/lib/inject.js +40 -4
- 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 +655 -0
- package/lib/scope.js +16 -4
- package/lib/semantic.js +66 -10
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +138 -1
- package/lib/store.js +132 -6
- package/lib/summarize.js +30 -0
- package/lib/tools.js +11 -2
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/publish-gate.mjs +197 -0
- 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 +403 -62
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +63 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/embedding.js +116 -15
- package/src/entities/extractor.js +4 -0
- package/src/index.js +81 -12
- package/src/inject.js +40 -4
- 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 +655 -0
- package/src/scope.js +16 -4
- package/src/semantic.js +66 -10
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +138 -1
- package/src/store.js +132 -6
- package/src/summarize.js +30 -0
- package/src/tools.js +11 -2
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +244 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +510 -2
- package/test/continuity.test.js +84 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/inject-recall.test.js +32 -4
- package/test/lib-smoke.test.js +20 -0
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -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 +636 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -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 +93 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
- package/test/workflow-guards.test.js +156 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// scripts/publish-gate.mjs 的回归测试。
|
|
2
|
+
//
|
|
3
|
+
// 这一步的价值全在「不许假绿」上:原先的 Verify published version 是
|
|
4
|
+
// echo "Published: $(npm view "@modusensus/dsh-mneme@${V}" version)"
|
|
5
|
+
// 命令替换里的失败不让 `set -e` 退出 —— v0.8.16 那次日志里明明 `404 No match found for version
|
|
6
|
+
// 0.8.16`,step 与 job 却都是 success。下面第一条与第三条各自钉住两端:
|
|
7
|
+
// · 版本已在 registry(幂等跳过路径)→ 立刻通过,不为传播延迟白等;
|
|
8
|
+
// · 等满预算仍看不见 → 必须 `ok:false` 且入口退出 1。
|
|
9
|
+
// 谁要是把「拿不到」改成「跳过校验」或「返回成功」,第三条会红。
|
|
10
|
+
//
|
|
11
|
+
// 全部用例走假时钟 + 注入 probe:零网络、零真实等待,跑起来是毫秒级。
|
|
12
|
+
// 还有一条走真进程(spawnSync)的端到端用例:工作流红不红只看真进程退出码,函数级断言钉不到
|
|
13
|
+
// 「主模块判别 + process.exitCode」那条路径。
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { spawnSync } from "node:child_process";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
DEFAULT_INTERVAL_MS,
|
|
22
|
+
DEFAULT_TIMEOUT_MS,
|
|
23
|
+
PACKAGE_NAME,
|
|
24
|
+
main,
|
|
25
|
+
parseArgs,
|
|
26
|
+
probeVersion,
|
|
27
|
+
versionUrl,
|
|
28
|
+
waitForPublished
|
|
29
|
+
} from "../scripts/publish-gate.mjs";
|
|
30
|
+
|
|
31
|
+
/** 被测入口的真实路径:端到端用例要把它当子进程跑起来。 */
|
|
32
|
+
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
|
33
|
+
|
|
34
|
+
/** 假时钟:只有 sleep() 会让时间前进,于是超时逻辑可以确定性断言。 */
|
|
35
|
+
function fakeClock() {
|
|
36
|
+
let t = 0;
|
|
37
|
+
return {
|
|
38
|
+
now: () => t,
|
|
39
|
+
sleep: async (ms) => { t += ms; },
|
|
40
|
+
slept: () => t
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const noop = () => {};
|
|
45
|
+
|
|
46
|
+
/** 前 `misses` 次报 404、之后可见的 probe;锁的是「重试真的发生了」。 */
|
|
47
|
+
function probeMissingTimes(misses, { version = "0.8.16" } = {}) {
|
|
48
|
+
let calls = 0;
|
|
49
|
+
return async () => {
|
|
50
|
+
calls += 1;
|
|
51
|
+
if (calls <= misses) return { ok: false, reason: "missing", detail: "HTTP 404,registry 上还看不到" };
|
|
52
|
+
return { ok: true, version, tarball: "https://registry.npmjs.org/x/-/x-0.8.16.tgz" };
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test("版本已在 registry:首次探测即通过,不为传播延迟白等(幂等跳过路径)", async () => {
|
|
57
|
+
const clock = fakeClock();
|
|
58
|
+
let sleeps = 0;
|
|
59
|
+
const result = await waitForPublished("0.8.16", {
|
|
60
|
+
now: clock.now,
|
|
61
|
+
sleep: async (ms) => { sleeps += 1; await clock.sleep(ms); },
|
|
62
|
+
probe: probeMissingTimes(0),
|
|
63
|
+
log: noop
|
|
64
|
+
});
|
|
65
|
+
assert.equal(result.ok, true);
|
|
66
|
+
assert.equal(result.attempts, 1);
|
|
67
|
+
assert.equal(result.waitedMs, 0);
|
|
68
|
+
assert.equal(sleeps, 0, "已发布路径不该有任何等待");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("传播延迟:前两次 404、第三次可见 → 通过,且等待时间与重试次数如实报出", async () => {
|
|
72
|
+
const clock = fakeClock();
|
|
73
|
+
const result = await waitForPublished("0.8.16", {
|
|
74
|
+
now: clock.now,
|
|
75
|
+
sleep: clock.sleep,
|
|
76
|
+
intervalMs: 15000,
|
|
77
|
+
probe: probeMissingTimes(2),
|
|
78
|
+
log: noop
|
|
79
|
+
});
|
|
80
|
+
assert.equal(result.ok, true);
|
|
81
|
+
assert.equal(result.attempts, 3);
|
|
82
|
+
assert.equal(result.waitedMs, 30000, "两次间隔的等待要计入");
|
|
83
|
+
assert.ok(result.tarball.includes("0.8.16"));
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("等满预算仍看不见:必须 ok:false + reason timeout(防的就是 v0.8.16 那种假绿)", async () => {
|
|
87
|
+
const clock = fakeClock();
|
|
88
|
+
const result = await waitForPublished("0.8.16", {
|
|
89
|
+
now: clock.now,
|
|
90
|
+
sleep: clock.sleep,
|
|
91
|
+
timeoutMs: 30000,
|
|
92
|
+
intervalMs: 15000,
|
|
93
|
+
probe: probeMissingTimes(Infinity),
|
|
94
|
+
log: noop
|
|
95
|
+
});
|
|
96
|
+
assert.equal(result.ok, false, "拿不到版本时绝不是成功");
|
|
97
|
+
assert.equal(result.reason, "timeout");
|
|
98
|
+
assert.equal(result.attempts, 3, "t=0 / 15s / 30s 各一次探测");
|
|
99
|
+
assert.equal(result.waitedMs, 30000);
|
|
100
|
+
assert.match(result.lastError, /404/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("探测自己耗时到超过预算、但回来时版本已可见:仍然通过(预算约束等待,不约束判定)", async () => {
|
|
104
|
+
// 锁的是刻意选择:单次探测带 HTTP 超时,晚到的成功最多超出预算十几秒;而版本真的在 registry 上
|
|
105
|
+
// 就是发布成功,为晚到几秒判红是假红——闸门要消灭的是假绿,不该顺手制造另一种失真。
|
|
106
|
+
const clock = fakeClock();
|
|
107
|
+
const result = await waitForPublished("0.8.16", {
|
|
108
|
+
now: clock.now,
|
|
109
|
+
sleep: clock.sleep,
|
|
110
|
+
timeoutMs: 15000,
|
|
111
|
+
intervalMs: 15000,
|
|
112
|
+
probe: async () => {
|
|
113
|
+
await clock.sleep(20000); // 这一次探测自己就把预算耗穿了
|
|
114
|
+
return { ok: true, version: "0.8.16", tarball: "" };
|
|
115
|
+
},
|
|
116
|
+
log: noop
|
|
117
|
+
});
|
|
118
|
+
assert.equal(result.ok, true);
|
|
119
|
+
assert.equal(result.attempts, 1);
|
|
120
|
+
assert.equal(result.waitedMs, 20000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("预算不是间隔整数倍时:最后一轮只睡到超时点,不多睡一整轮", async () => {
|
|
124
|
+
const clock = fakeClock();
|
|
125
|
+
const result = await waitForPublished("0.8.16", {
|
|
126
|
+
now: clock.now,
|
|
127
|
+
sleep: clock.sleep,
|
|
128
|
+
timeoutMs: 20000,
|
|
129
|
+
intervalMs: 15000,
|
|
130
|
+
probe: probeMissingTimes(Infinity),
|
|
131
|
+
log: noop
|
|
132
|
+
});
|
|
133
|
+
assert.equal(result.ok, false);
|
|
134
|
+
assert.equal(result.attempts, 3, "t=0 / 15s / 20s(最后一次落在超时点上)");
|
|
135
|
+
assert.equal(result.waitedMs, 20000);
|
|
136
|
+
assert.equal(clock.slept(), 20000, "总睡眠时间恰为预算,而不是 30000");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("网络异常同样计入重试(既不崩也不当作成功),最终以最后一次原因报出", async () => {
|
|
140
|
+
const clock = fakeClock();
|
|
141
|
+
let calls = 0;
|
|
142
|
+
const result = await waitForPublished("0.8.16", {
|
|
143
|
+
now: clock.now,
|
|
144
|
+
sleep: clock.sleep,
|
|
145
|
+
timeoutMs: 15000,
|
|
146
|
+
intervalMs: 15000,
|
|
147
|
+
probe: async () => { calls += 1; return { ok: false, detail: `请求失败:fetch failed(第 ${calls} 次)` }; },
|
|
148
|
+
log: noop
|
|
149
|
+
});
|
|
150
|
+
assert.equal(result.ok, false);
|
|
151
|
+
assert.equal(result.attempts, 2, "异常也继续重试");
|
|
152
|
+
assert.match(result.lastError, /第 2 次/, "报的是最后一次原因");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("probe 返回形状异常(undefined)也不炸,按拿不到处理", async () => {
|
|
156
|
+
const clock = fakeClock();
|
|
157
|
+
const result = await waitForPublished("0.8.16", {
|
|
158
|
+
now: clock.now,
|
|
159
|
+
sleep: clock.sleep,
|
|
160
|
+
timeoutMs: 0,
|
|
161
|
+
intervalMs: 15000,
|
|
162
|
+
probe: async () => undefined,
|
|
163
|
+
log: noop
|
|
164
|
+
});
|
|
165
|
+
assert.equal(result.ok, false);
|
|
166
|
+
assert.equal(result.reason, "timeout");
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("默认预算:超时 10 分钟 / 间隔 15 秒(覆盖实测 ~4.5 分钟的传播延迟)", () => {
|
|
170
|
+
assert.equal(DEFAULT_TIMEOUT_MS, 600000);
|
|
171
|
+
assert.equal(DEFAULT_INTERVAL_MS, 15000);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("versionUrl:scoped 包名整体编码,origin 尾斜杠不重复", () => {
|
|
175
|
+
assert.equal(
|
|
176
|
+
versionUrl(PACKAGE_NAME, "0.8.16"),
|
|
177
|
+
"https://registry.npmjs.org/%40modusensus%2Fdsh-mneme/0.8.16"
|
|
178
|
+
);
|
|
179
|
+
assert.equal(
|
|
180
|
+
versionUrl(PACKAGE_NAME, "0.8.16", "https://registry.npmjs.org/"),
|
|
181
|
+
"https://registry.npmjs.org/%40modusensus%2Fdsh-mneme/0.8.16"
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("probeVersion:404 = 还没传播,200 且版本一致 = 可见(带 tarball)", async () => {
|
|
186
|
+
const missing = await probeVersion("0.8.16", {
|
|
187
|
+
fetchImpl: async () => ({ ok: false, status: 404, json: async () => ({}) })
|
|
188
|
+
});
|
|
189
|
+
assert.equal(missing.ok, false);
|
|
190
|
+
assert.equal(missing.reason, "missing");
|
|
191
|
+
|
|
192
|
+
const found = await probeVersion("0.8.16", {
|
|
193
|
+
fetchImpl: async () => ({
|
|
194
|
+
ok: true,
|
|
195
|
+
status: 200,
|
|
196
|
+
json: async () => ({ version: "0.8.16", dist: { tarball: "https://registry.npmjs.org/x/-/x-0.8.16.tgz" } })
|
|
197
|
+
})
|
|
198
|
+
});
|
|
199
|
+
assert.equal(found.ok, true);
|
|
200
|
+
assert.equal(found.tarball, "https://registry.npmjs.org/x/-/x-0.8.16.tgz");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("probeVersion:请求抛错 / 5xx / 响应不是 JSON / 版本对不上,都折成 ok:false 而不抛", async () => {
|
|
204
|
+
const net = await probeVersion("0.8.16", { fetchImpl: async () => { throw new Error("fetch failed"); } });
|
|
205
|
+
assert.equal(net.reason, "network");
|
|
206
|
+
|
|
207
|
+
const http = await probeVersion("0.8.16", { fetchImpl: async () => ({ ok: false, status: 500, json: async () => ({}) }) });
|
|
208
|
+
assert.equal(http.reason, "http");
|
|
209
|
+
|
|
210
|
+
const badJson = await probeVersion("0.8.16", {
|
|
211
|
+
fetchImpl: async () => ({ ok: true, status: 200, json: async () => { throw new Error("boom"); } })
|
|
212
|
+
});
|
|
213
|
+
assert.equal(badJson.reason, "bad-json");
|
|
214
|
+
|
|
215
|
+
const mismatch = await probeVersion("0.8.16", {
|
|
216
|
+
fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({ version: "0.8.15" }) })
|
|
217
|
+
});
|
|
218
|
+
assert.equal(mismatch.ok, false);
|
|
219
|
+
assert.equal(mismatch.reason, "mismatch");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("parseArgs:位置参数给版本号,可覆盖预算;缺版本号如实回报", () => {
|
|
223
|
+
assert.deepEqual(parseArgs(["0.8.16"]), { ok: true, version: "0.8.16", timeoutMs: 600000, intervalMs: 15000 });
|
|
224
|
+
assert.deepEqual(parseArgs(["0.8.16", "--timeout-ms", "1000", "--interval-ms", "500"]), {
|
|
225
|
+
ok: true, version: "0.8.16", timeoutMs: 1000, intervalMs: 500
|
|
226
|
+
});
|
|
227
|
+
assert.equal(parseArgs([]).reason, "missing-version");
|
|
228
|
+
assert.equal(parseArgs(["0.8.16", "0.8.17"]).reason, "missing-version");
|
|
229
|
+
assert.equal(parseArgs(["0.8.16", "--timeout-ms", "abc"]).ok, false);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("入口退出码:通过 → 0;超时 → 1 且 stderr 指明去翻 publish 步日志", async () => {
|
|
233
|
+
const logs = [];
|
|
234
|
+
const errors = [];
|
|
235
|
+
const okCode = await main(["0.8.16"], {
|
|
236
|
+
log: (line) => logs.push(line),
|
|
237
|
+
error: (line) => errors.push(line),
|
|
238
|
+
wait: async (version) => ({ ok: true, version, attempts: 1, waitedMs: 0, tarball: "" })
|
|
239
|
+
});
|
|
240
|
+
assert.equal(okCode, 0);
|
|
241
|
+
assert.equal(errors.length, 0);
|
|
242
|
+
|
|
243
|
+
const failCode = await main(["0.8.16"], {
|
|
244
|
+
log: noop,
|
|
245
|
+
error: (line) => errors.push(line),
|
|
246
|
+
wait: async () => ({ ok: false, reason: "timeout", attempts: 40, waitedMs: 600000, lastError: "HTTP 404" })
|
|
247
|
+
});
|
|
248
|
+
assert.equal(failCode, 1, "闸门未通过必须以非零退出让 job 变红");
|
|
249
|
+
assert.ok(errors.some((line) => line.includes("发布闸门未通过")));
|
|
250
|
+
assert.ok(errors.some((line) => line.includes("publish 步")));
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("入口退出码:缺版本号 → 1 并打印用法", async () => {
|
|
254
|
+
const errors = [];
|
|
255
|
+
const code = await main([], { log: noop, error: (line) => errors.push(line), wait: async () => ({ ok: true }) });
|
|
256
|
+
assert.equal(code, 1);
|
|
257
|
+
assert.ok(errors.some((line) => line.includes("用法")));
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("真进程端到端:不存在的版本 → 退出码 1 且 stderr 指明原因(这一步红了 job 才会红)", () => {
|
|
261
|
+
// 单测里的 main() 是函数调用,钉不到「主模块判别 + process.exitCode」这条真实退出路径——而
|
|
262
|
+
// 工作流红不红只看真进程的退出码。目标版本刻意取一个永不存在、与发布无关的名字:registry 回
|
|
263
|
+
// 404(连不上网络则是 network 原因),两种都会走到同一个超时分支,所以这条例外不引入网络依赖。
|
|
264
|
+
const result = spawnSync(
|
|
265
|
+
process.execPath,
|
|
266
|
+
[join(scriptDir, "..", "scripts", "publish-gate.mjs"), "0.0.0-mneme-gate-test", "--timeout-ms", "1", "--interval-ms", "1"],
|
|
267
|
+
{ encoding: "utf8" }
|
|
268
|
+
);
|
|
269
|
+
assert.equal(result.status, 1, `期望非零退出,实际 ${result.status}(stderr: ${result.stderr})`);
|
|
270
|
+
assert.match(result.stderr, /发布闸门未通过/);
|
|
271
|
+
assert.match(result.stdout, /等待 @modusensus\/dsh-mneme@0\.0\.0-mneme-gate-test/);
|
|
272
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import { createStore } from "../src/store.js";
|
|
7
|
+
|
|
8
|
+
// 2026-10-14 找回:recall_runs 的滚动清理(函数 + 启动调用)在 dd0ed78「release:
|
|
9
|
+
// v0.7.12」里被整块弄丢,而配置键 recallRetentionDays、README.md 与
|
|
10
|
+
// docs/CONFIGURATION.md 一直宣称它有保留期 —— 于是本机那张表只增不减,没人发现
|
|
11
|
+
// (2026-10-13 当天实测就从 16,041 涨到 20,510 行)。
|
|
12
|
+
//
|
|
13
|
+
// 这组测试故意分两半,因为**弄丢的正是「两半都在」这件事**:
|
|
14
|
+
// 1. 行为锁:函数真的删对行、边界正确、空表不炸;
|
|
15
|
+
// 2. 接线锁:index.js 的启动块真的调它,且读的是装配后的 cfg(不是 rawCfg)。
|
|
16
|
+
// 只锁 1 不锁 2,函数写得再对也没人调;只锁 2 不锁 1,调用调的可能是空壳。
|
|
17
|
+
// 第二半用源码文本断言,理由与 test/client.test.js 的 bilingual 锁同款:这是
|
|
18
|
+
// 「接线还在不在」的问题,运行时冒烟测不到一个**在运维时才会跑**的启动分支。
|
|
19
|
+
|
|
20
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const srcText = (name) => readFileSync(join(here, "..", "src", name), "utf8");
|
|
22
|
+
|
|
23
|
+
function seedRun(store, { query, createdAt }) {
|
|
24
|
+
store.saveRecallRun({
|
|
25
|
+
query,
|
|
26
|
+
mode: "auto",
|
|
27
|
+
topK: 5,
|
|
28
|
+
threshold: null,
|
|
29
|
+
candidates: [],
|
|
30
|
+
created_at: createdAt
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const daysAgo = (n) => new Date(Date.now() - n * 86400000).toISOString();
|
|
35
|
+
|
|
36
|
+
test("保留期:删掉窗口外的行、留下窗口内的行,返回值是真实删除数", () => {
|
|
37
|
+
const store = createStore(":memory:");
|
|
38
|
+
seedRun(store, { query: "老", createdAt: daysAgo(120) });
|
|
39
|
+
seedRun(store, { query: "也老", createdAt: daysAgo(91) });
|
|
40
|
+
seedRun(store, { query: "新", createdAt: daysAgo(89) });
|
|
41
|
+
seedRun(store, { query: "很新", createdAt: daysAgo(1) });
|
|
42
|
+
|
|
43
|
+
const removed = store.purgeRecallRunsOlderThan(90);
|
|
44
|
+
assert.equal(removed, 2, "只有窗口外的两行该被删");
|
|
45
|
+
|
|
46
|
+
// 两侧都排过再比:assert 的是「留下了哪两行」,不是它们的物理顺序
|
|
47
|
+
const left = store.listRecallRunsSince(daysAgo(3650)).rows.map((r) => r.query).sort();
|
|
48
|
+
assert.deepEqual(left, ["很新", "新"].sort());
|
|
49
|
+
store.close();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("保留期:边界按字典序比较,窗口内的留、窗口外的删(两侧各留 1 小时裕量)", () => {
|
|
53
|
+
const store = createStore(":memory:");
|
|
54
|
+
// 不骑刀刃:cutoff 是在**清理那一刻**用 Date.now() 现算的,而造数据也取一次
|
|
55
|
+
// Date.now(),测试跑几毫秒就足以让「正好 90 天前」翻到界外。这里两侧各留
|
|
56
|
+
// 1 小时,锁的是比较方向(`created_at < cutoff` 才删),不是毫秒级巧合。
|
|
57
|
+
const boundary = 90 * 86400000;
|
|
58
|
+
seedRun(store, { query: "窗口内一小时", createdAt: new Date(Date.now() - boundary + 3600000).toISOString() });
|
|
59
|
+
seedRun(store, { query: "窗口外一小时", createdAt: new Date(Date.now() - boundary - 3600000).toISOString() });
|
|
60
|
+
|
|
61
|
+
const removed = store.purgeRecallRunsOlderThan(90);
|
|
62
|
+
assert.equal(removed, 1, "越过界的才删,贴近界的要留");
|
|
63
|
+
assert.deepEqual(store.listRecallRunsSince(daysAgo(3650)).rows.map((r) => r.query), ["窗口内一小时"]);
|
|
64
|
+
store.close();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("保留期:空表返回 0,不抛(清理是 bookkeeping,不许挡启动)", () => {
|
|
68
|
+
const store = createStore(":memory:");
|
|
69
|
+
assert.equal(store.purgeRecallRunsOlderThan(90), 0);
|
|
70
|
+
store.close();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("接线锁:index.js 启动块真的调 purgeRecallRunsOlderThan,且读装配后的 cfg", () => {
|
|
74
|
+
const index = srcText("index.js");
|
|
75
|
+
assert.match(index, /store\.purgeRecallRunsOlderThan\(recallRetentionDays\)/,
|
|
76
|
+
"启动期必须真的调用清理——这对曾在 dd0ed78 里双双蒸发");
|
|
77
|
+
assert.match(index, /cfg\.recallRetentionDays/,
|
|
78
|
+
"必须读装配后的 cfg(面板改保留期才生效),不能读 rawCfg");
|
|
79
|
+
// 失败必须真的出声。这一对当初就是**无声**蒸发的:函数与调用一起没了、没人发现,
|
|
80
|
+
// 表涨到两万余行。清理再失败一次若仍然静默,同一类回归照样看不见——所以这里
|
|
81
|
+
// 两个方向都锁:接住(catch(err),不 rethrow、不挡启动)与出声(真调 logger.warn)。
|
|
82
|
+
// 只锁「不挡启动」会放过静默吞,只锁「出声」会放过把异常抛出去挡启动。
|
|
83
|
+
const at = index.indexOf("store.purgeRecallRunsOlderThan(recallRetentionDays)");
|
|
84
|
+
assert.ok(at > 0, "启动期必须真的调用清理——这对曾在 dd0ed78 里双双蒸发");
|
|
85
|
+
const tail = index.slice(at, at + 600);
|
|
86
|
+
assert.match(tail, /catch \(err\) \{/, "清理失败要接住,不许挡插件启动");
|
|
87
|
+
assert.match(tail, /ctx\.logger\?\.warn\?\.\(/, "清理失败要 warn 出声,静默吞等于把回归再藏一次");
|
|
88
|
+
// 就近确认它落在同一族启动清理之间:失败表 → llm_audit → distill → recall_runs
|
|
89
|
+
const llm = index.indexOf("store.deleteOldLlmAudits");
|
|
90
|
+
const distill = index.indexOf("store.clearDistillRecords");
|
|
91
|
+
assert.ok(llm > 0 && distill > 0 && at > llm, "recall_runs 清理应排在 llm_audit 之后");
|
|
92
|
+
assert.ok(at > distill, "recall_runs 清理应排在 distill_records 之后(与现有启动顺序一致)");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("接线锁:store.js 导出 purgeRecallRunsOlderThan(函数存在但没导出同样是断线)", () => {
|
|
96
|
+
const store = srcText("store.js");
|
|
97
|
+
assert.match(store, /function purgeRecallRunsOlderThan\(days\)/);
|
|
98
|
+
assert.match(store, /^\s*purgeRecallRunsOlderThan,\s*$/m, "必须在 createStore 的返回对象里导出");
|
|
99
|
+
assert.match(store, /DELETE FROM recall_runs WHERE created_at < \?/);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// 启动期清理是**一族的四个兄弟**:失败表 / llm_audit / distill_records / recall_runs。
|
|
103
|
+
// 上面那条只锁了 recall_runs 一个,但四个的注释互相引用(llm_audit 那条原文写着
|
|
104
|
+
// 「失败只 warn,与失败表清理同款」),而 2026-10-14 实测前三个全是
|
|
105
|
+
// `catch { /* non-fatal */ }` —— 静默吞,与注释宣称的相反。recall_runs 那一对当初
|
|
106
|
+
// 就是无声蒸发的,所以这一族必须一起锁:只要有一个偷偷退回静默吞,这条就红。
|
|
107
|
+
test("接线锁:四个启动期清理都必须出声(catch(err) + logger.warn,不许静默吞)", () => {
|
|
108
|
+
const index = srcText("index.js");
|
|
109
|
+
const family = [
|
|
110
|
+
["失败表", "store.deleteOldFailures("],
|
|
111
|
+
["llm_audit", "store.deleteOldLlmAudits("],
|
|
112
|
+
["distill_records", "store.clearDistillRecords("],
|
|
113
|
+
["recall_runs", "store.purgeRecallRunsOlderThan(recallRetentionDays)"]
|
|
114
|
+
];
|
|
115
|
+
for (const [label, call] of family) {
|
|
116
|
+
const at = index.indexOf(call);
|
|
117
|
+
assert.ok(at > 0, `${label} 的启动期清理调用必须在(弄丢过一次的就是这一族)`);
|
|
118
|
+
const tail = index.slice(at, at + 700);
|
|
119
|
+
assert.match(tail, /catch \(err\) \{/, `${label} 清理失败要接住,不许挡插件启动`);
|
|
120
|
+
assert.match(tail, /ctx\.logger\?\.warn\?\.\(/, `${label} 清理失败要 warn 出声,静默吞等于把回归再藏一次`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("接线锁:配置键与文档对外宣称的保留期一致(键还在、默认 90 天)", () => {
|
|
125
|
+
const cfgText = srcText("config.js");
|
|
126
|
+
assert.match(cfgText, /recallRetentionDays: z\.natural\(\)\.min\(1\)\.max\(3650\)\.default\(90\)/,
|
|
127
|
+
"配置键被改或删掉时,这条和文档一起要动——别让文档继续说谎");
|
|
128
|
+
});
|
|
@@ -28,13 +28,13 @@ test("Issue #128: fingerprint match no longer blocks backfill; archived rows are
|
|
|
28
28
|
};
|
|
29
29
|
|
|
30
30
|
const logs = [];
|
|
31
|
-
const
|
|
31
|
+
const summary = await backfillMissingEmbeddings({
|
|
32
32
|
store, embedder, vectorIndex,
|
|
33
33
|
logger: { info: (m) => logs.push(m) },
|
|
34
34
|
rateLimitMs: 0
|
|
35
35
|
});
|
|
36
36
|
|
|
37
|
-
assert.equal(indexed, 1, "the active row is backfilled despite the matching fingerprint");
|
|
37
|
+
assert.equal(summary.indexed, 1, "the active row is backfilled despite the matching fingerprint");
|
|
38
38
|
assert.equal(store.getEmbeddings([active.id]).get(active.id)?.length, 3, "active row now carries a vector");
|
|
39
39
|
assert.equal(store.getEmbeddings([dead.id]).get(dead.id), undefined, "archived row is skipped");
|
|
40
40
|
assert.equal(vectorIndex.modelHash(), "mock-model#abc", "fingerprint unchanged (markModel idempotent)");
|
|
@@ -51,12 +51,91 @@ test("Issue #128: backfill is bounded by maxTotal; the rest stays queued", async
|
|
|
51
51
|
const embedder = { embedSingle: async () => [1, 0, 0], modelHash: "h#1", dimension: 3 };
|
|
52
52
|
const vectorIndex = createVectorIndex({ store });
|
|
53
53
|
|
|
54
|
-
const
|
|
54
|
+
const summary = await backfillMissingEmbeddings({
|
|
55
55
|
store, embedder, vectorIndex,
|
|
56
56
|
logger: { info() {} },
|
|
57
57
|
maxTotal: 4, batchSize: 2, rateLimitMs: 0
|
|
58
58
|
});
|
|
59
|
-
assert.equal(indexed, 4, "stops at the maxTotal bound");
|
|
59
|
+
assert.equal(summary.indexed, 4, "stops at the maxTotal bound");
|
|
60
|
+
// A capped run must SAY it was capped. The old bare-count return made a
|
|
61
|
+
// capped run indistinguishable from a run that drained the backlog.
|
|
62
|
+
assert.equal(summary.finishReason, "capped");
|
|
63
|
+
assert.equal(summary.stillStale, true, "reports that work remains");
|
|
60
64
|
assert.equal(store.needsEmbedding(10).length, 1, "remaining rows stay queued for the next boot");
|
|
61
65
|
store.close();
|
|
62
66
|
});
|
|
67
|
+
|
|
68
|
+
test("maxTotal: 0 drains the whole backlog in one run", async () => {
|
|
69
|
+
const store = createStore(":memory:");
|
|
70
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
71
|
+
for (let i = 0; i < 5; i++) {
|
|
72
|
+
service.saveWithDedupe({ type: "preference", title: `记忆${i}`, content: `内容${i}` });
|
|
73
|
+
}
|
|
74
|
+
const embedder = { embedSingle: async () => [1, 0, 0], modelHash: "h#1", dimension: 3 };
|
|
75
|
+
const vectorIndex = createVectorIndex({ store });
|
|
76
|
+
|
|
77
|
+
const summary = await backfillMissingEmbeddings({
|
|
78
|
+
store, embedder, vectorIndex,
|
|
79
|
+
logger: { info() {} },
|
|
80
|
+
maxTotal: 0, batchSize: 2, rateLimitMs: 0
|
|
81
|
+
});
|
|
82
|
+
assert.equal(summary.indexed, 5, "no cap: every row is embedded");
|
|
83
|
+
assert.equal(summary.finishReason, "drained");
|
|
84
|
+
assert.equal(summary.stillStale, false, "nothing left behind");
|
|
85
|
+
store.close();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("a full-model switch re-embeds old vectors, not just the empty ones", async () => {
|
|
89
|
+
// The old selector only matched NULL/'' — vectors from the previous model
|
|
90
|
+
// stayed "present" forever and were never re-embedded, so they kept
|
|
91
|
+
// inflating coverage while being incomparable to the new query embedding.
|
|
92
|
+
const store = createStore(":memory:");
|
|
93
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
94
|
+
const a = service.saveWithDedupe({ type: "preference", title: "旧向量", content: "甲" }).memory;
|
|
95
|
+
const b = service.saveWithDedupe({ type: "preference", title: "无向量", content: "乙" }).memory;
|
|
96
|
+
store.setEmbedding(a.id, [1, 0, 0], "old-model#aaa");
|
|
97
|
+
// b stays empty.
|
|
98
|
+
|
|
99
|
+
const embedder = { embedSingle: async () => [0, 1, 0], modelHash: "new-model#bbb", dimension: 3 };
|
|
100
|
+
const vectorIndex = createVectorIndex({ store });
|
|
101
|
+
const summary = await backfillMissingEmbeddings({
|
|
102
|
+
store, embedder, vectorIndex, logger: { info() {} }, rateLimitMs: 0
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
assert.equal(summary.indexed, 2, "both the stale-model row and the empty row are re-embedded");
|
|
106
|
+
assert.equal(summary.finishReason, "drained");
|
|
107
|
+
assert.equal(store.needsEmbeddingFor("new-model#bbb", 10).length, 0, "nothing stale remains");
|
|
108
|
+
const breakdown = store.embeddingModelBreakdown();
|
|
109
|
+
assert.equal(breakdown.length, 1, "every vector now carries the new model");
|
|
110
|
+
assert.equal(breakdown[0].model, "new-model#bbb");
|
|
111
|
+
assert.equal(breakdown[0].c, 2);
|
|
112
|
+
store.close();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("rows with unknown provenance (NULL embedding_model) count as stale", async () => {
|
|
116
|
+
const store = createStore(":memory:");
|
|
117
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
118
|
+
const r = service.saveWithDedupe({ type: "preference", title: "存量", content: "丙" }).memory;
|
|
119
|
+
// Simulate a pre-migration row: vector present, provenance unknown.
|
|
120
|
+
store.setEmbedding(r.id, [1, 0, 0]);
|
|
121
|
+
|
|
122
|
+
assert.equal(store.needsEmbedding(10).length, 0, "old selector sees nothing to do");
|
|
123
|
+
assert.equal(store.needsEmbeddingFor("new-model#bbb", 10).length, 1, "new selector flags unknown origin");
|
|
124
|
+
assert.equal(store.hasStaleEmbedding("new-model#bbb"), true);
|
|
125
|
+
store.close();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("setEmbedding records provenance and clearing the vector clears it", async () => {
|
|
129
|
+
const store = createStore(":memory:");
|
|
130
|
+
const service = createService({ store, mirror: null, config: {} });
|
|
131
|
+
const r = service.saveWithDedupe({ type: "preference", title: "出身", content: "丁" }).memory;
|
|
132
|
+
|
|
133
|
+
store.setEmbedding(r.id, [1, 0, 0], "m#1");
|
|
134
|
+
assert.equal(store.embeddingModelBreakdown()[0].model, "m#1");
|
|
135
|
+
|
|
136
|
+
store.setEmbedding(r.id, null);
|
|
137
|
+
const row = store.db.prepare("SELECT embedding, embedding_model FROM memories WHERE id = ?").get(r.id);
|
|
138
|
+
assert.equal(row.embedding, null);
|
|
139
|
+
assert.equal(row.embedding_model, null, "a cleared vector never keeps a stale model name");
|
|
140
|
+
store.close();
|
|
141
|
+
});
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// 锁发布准备里「改文档表格」这一段的三件事,它们各自都静默失效过或随时会:
|
|
2
|
+
//
|
|
3
|
+
// 1. 根 README 的路线图版本列写的是两段式 **v0.9**,旧实现只认三段式 **v0.9.0**,于是
|
|
4
|
+
// 对根 README 一个字节没改,打印的却是「无版本表格行,跳过」——把「没做」说成「不需要做」。
|
|
5
|
+
// 2. 往 CRLF 文件里插一行而不显式保行尾,会把文件混成一半 CRLF 一半 LF(changelog-prep 踩过)。
|
|
6
|
+
// 3. SECURITY.md 的支持版本表跨 minor 时必须由人挪格。脚本只核对;核对一旦退化成永真,
|
|
7
|
+
// 0.10.0 就能带着「0.9.x 活跃开发中」的表格发出去——那是安全策略文档里的谎话。
|
|
8
|
+
//
|
|
9
|
+
// 前两类直接读真实 README 断言(正则被改窄时,用户会静默看到「跳过」,而测试必须变红)。
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { checkSupportTable, scanReleaseRows, syncReadmeTables } from "../scripts/release-docs.mjs";
|
|
16
|
+
|
|
17
|
+
const pkgRoot = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
18
|
+
const repoRoot = path.dirname(pkgRoot);
|
|
19
|
+
const rootReadme = readFileSync(path.join(repoRoot, "README.md"), "utf8");
|
|
20
|
+
const pkgReadme = readFileSync(path.join(pkgRoot, "README.md"), "utf8");
|
|
21
|
+
const securityDoc = readFileSync(path.join(repoRoot, "SECURITY.md"), "utf8");
|
|
22
|
+
const { version } = JSON.parse(readFileSync(path.join(pkgRoot, "package.json"), "utf8"));
|
|
23
|
+
|
|
24
|
+
const CRLF = "\r\n";
|
|
25
|
+
const crlf = (...lines) => lines.join(CRLF) + CRLF;
|
|
26
|
+
|
|
27
|
+
test("两份真实 README 的版本表都还认得出(根按 minor、子按 patch)", () => {
|
|
28
|
+
const root = scanReleaseRows(rootReadme);
|
|
29
|
+
assert.ok(root.two.length >= 3, `根 README 的路线图应解析出至少 3 行两段式版本行,实际 ${root.two.length}`);
|
|
30
|
+
assert.equal(root.three.length, 0,
|
|
31
|
+
"根 README 的路线图按 minor 排,不该出现三段式版本行——出现了说明表格形状变了,插入位置需要重算");
|
|
32
|
+
const sub = scanReleaseRows(pkgReadme);
|
|
33
|
+
assert.ok(sub.three.length >= 1, `子 README 的版本历史应解析出至少 1 行三段式版本行,实际 ${sub.three.length}`);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("根 README 的两段式路线图能插进新 minor 占位行(旧实现零命中)", () => {
|
|
37
|
+
const sample = crlf(
|
|
38
|
+
"| 版本 | 主题 | 状态 |",
|
|
39
|
+
"|------|------|------|",
|
|
40
|
+
"| **v0.8** | 生态化 | ✅ 已发布(至 v0.8.16) |",
|
|
41
|
+
"| **v0.9** | 手法 beta | ✅ 已发布(v0.9.0) |",
|
|
42
|
+
);
|
|
43
|
+
const { text, actions } = syncReadmeTables(sample, "0.10.0", "2026-11-01");
|
|
44
|
+
assert.equal(actions.filter((a) => a.kind === "insert").length, 1, "应插入且只插入一行");
|
|
45
|
+
assert.match(text, /\| \*\*v0\.10\*\* \| (待填) \| ✅ 已发布(v0\.10\.0) \|/,
|
|
46
|
+
"占位行按 minor 写 v0.10,状态写这次要发出去的完整版本");
|
|
47
|
+
// 插在 v0.9 之后,而不是表尾——表尾将来会有更高的计划行
|
|
48
|
+
assert.ok(text.indexOf("**v0.10**") > text.indexOf("**v0.9**"), "占位行应排在 v0.9 之后");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("真实根 README 的中英两张路线图各插一行(同一版本号,不能只插一张)", () => {
|
|
52
|
+
// 两张表版本行完全一样,旧实现把全文件版本行合成一个数组判断「已存在」,
|
|
53
|
+
// 于是先插的中文表会让英文表被跳过——英文读者永远等不到那一行。
|
|
54
|
+
const { actions } = syncReadmeTables(rootReadme, "0.10.0", "2026-11-01");
|
|
55
|
+
const inserts = actions.filter((a) => a.kind === "insert");
|
|
56
|
+
assert.equal(inserts.length, 2, "中英路线图各一行,实际:" + JSON.stringify(actions));
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("子 README 的版本历史插占位行时写完整 patch 号并带日期", () => {
|
|
60
|
+
const sample = crlf(
|
|
61
|
+
"| 版本 | 主题 |",
|
|
62
|
+
"|------|------|",
|
|
63
|
+
"| **v0.9.0**(2026-10-17) | 手法 beta |",
|
|
64
|
+
"| **v0.8.x** | 作用域隔离 |",
|
|
65
|
+
);
|
|
66
|
+
const { text } = syncReadmeTables(sample, "0.9.1", "2026-10-18");
|
|
67
|
+
assert.match(text, /\| \*\*v0\.9\.1\*\*(2026-10-18) \| (待填) \|/);
|
|
68
|
+
assert.ok(text.indexOf("**v0.9.1**") < text.indexOf("**v0.8.x**"),
|
|
69
|
+
"占位行要排在低于它的最新版本之后、概括行之前");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("路线图里上一版留下的「🚧 准备中」在发版准备时回填成已发布", () => {
|
|
73
|
+
const sample = crlf(
|
|
74
|
+
"| 版本 | 主题 | 状态 |",
|
|
75
|
+
"|------|------|------|",
|
|
76
|
+
"| **v0.9** | 手法 beta | 🚧 准备中 |",
|
|
77
|
+
);
|
|
78
|
+
const { text } = syncReadmeTables(sample, "0.9.0", "2026-10-17");
|
|
79
|
+
assert.match(text, /\| \*\*v0\.9\*\* \| 手法 beta \| ✅ 已发布(v0\.9\.0) \|/);
|
|
80
|
+
// 已经写着已发布的行不动:脚本跑在打 tag 之前,改写补丁号等于提前宣布一个还没发出去的版本。
|
|
81
|
+
const already = crlf(
|
|
82
|
+
"| 版本 | 主题 | 状态 |",
|
|
83
|
+
"|------|------|------|",
|
|
84
|
+
"| **v0.9** | 手法 beta | ✅ 已发布(v0.9.0) |",
|
|
85
|
+
);
|
|
86
|
+
assert.equal(syncReadmeTables(already, "0.9.1", "2026-10-18").text, already,
|
|
87
|
+
"已发布的补丁号由人在真正发出去之后更新,脚本不擅自提前改");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("插入占位行不改变行尾风格(CRLF 文件不会混进裸 LF)", () => {
|
|
91
|
+
const sample = crlf(
|
|
92
|
+
"| 版本 | 主题 |",
|
|
93
|
+
"|------|------|",
|
|
94
|
+
"| **v0.9.0**(2026-10-17) | 手法 beta |",
|
|
95
|
+
);
|
|
96
|
+
const { text } = syncReadmeTables(sample, "0.9.1", "2026-10-18");
|
|
97
|
+
const bareLf = (text.match(/(?<!\r)\n/g) || []).length;
|
|
98
|
+
assert.equal(bareLf, 0, `输出里出现了 ${bareLf} 个裸 LF——CRLF 文件被混写了一半 LF`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("重复跑是幂等的:已有目标的占位行就不再插第二行", () => {
|
|
102
|
+
const sample = crlf(
|
|
103
|
+
"| 版本 | 主题 |",
|
|
104
|
+
"|------|------|",
|
|
105
|
+
"| **v0.9.0**(2026-10-17) | 手法 beta |",
|
|
106
|
+
);
|
|
107
|
+
const once = syncReadmeTables(sample, "0.9.1", "2026-10-18").text;
|
|
108
|
+
const twice = syncReadmeTables(once, "0.9.1", "2026-10-18");
|
|
109
|
+
assert.equal(twice.text, once);
|
|
110
|
+
assert.deepEqual(twice.actions, [], "第二次应报告零变更,而不是再插一行");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("SECURITY.md 支持版本表:同 minor 通过,跨 minor 如实报出中英两处", () => {
|
|
114
|
+
const sameMinor = checkSupportTable(securityDoc, version);
|
|
115
|
+
assert.equal(sameMinor.ok, true, `真实 SECURITY.md 应与 package.json ${version} 一致:${sameMinor.problems.join(";")}`);
|
|
116
|
+
|
|
117
|
+
const nextMinor = `${version.split(".")[0]}.${Number(version.split(".")[1]) + 1}.0`;
|
|
118
|
+
const crossing = checkSupportTable(securityDoc, nextMinor);
|
|
119
|
+
assert.equal(crossing.ok, false, "跨 minor 时必须报错——否则会带着过时的支持范围发版");
|
|
120
|
+
assert.equal(crossing.problems.length, 2, "中英两张表各报一条:" + crossing.problems.join(";"));
|
|
121
|
+
assert.ok(crossing.problems.every((p) => p.includes(crossing.expected)),
|
|
122
|
+
"每条问题都要写出期望值,否则人不知道改成什么:" + crossing.problems.join(";"));
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("SECURITY.md 支持版本表:节缺失或表格形状变了也报错(不能静默通过)", () => {
|
|
126
|
+
assert.equal(checkSupportTable("# Security\n\n## 别的节\n", "0.9.0").ok, false);
|
|
127
|
+
const noRows = "## Supported Versions\r\n\r\n没有表格\r\n\r\n## 支持版本\r\n\r\n没有表格\r\n";
|
|
128
|
+
assert.equal(checkSupportTable(noRows, "0.9.0").ok, false,
|
|
129
|
+
"解析不出任何版本行时必须失败:正则失配不能变成永真");
|
|
130
|
+
});
|