@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,191 @@
|
|
|
1
|
+
import test from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createStore } from "../src/store.js";
|
|
7
|
+
import { createService } from "../src/service.js";
|
|
8
|
+
import { createMirror } from "../src/mirror.js";
|
|
9
|
+
|
|
10
|
+
// 回归(issue #430):写路径上的全表物化。此前每一次业务写都要走两处
|
|
11
|
+
// all() + 逐行 toRow(27 字段 + 三次 JSON.parse):
|
|
12
|
+
// 1. 质量过滤的近重复判定只要最近 20 条 content,却先物化整库再 slice(0,20)
|
|
13
|
+
// (5k 行实测 100ms 级 → 本批换成单列有界查询后 0.06ms 级);
|
|
14
|
+
// 2. syncMirror 先物化整库再逐行 filter 掉 archived/forgotten/document。
|
|
15
|
+
// 本批修复:store 新增单列有界读 recentContents();list() 两条分支的 SELECT *
|
|
16
|
+
// 换成显式列清单(memoryColumns,排除 embedding);syncMirror 改走 list 的无界
|
|
17
|
+
// 档 + SQL 侧过滤。下面四条锁不测墙钟(CI 矩阵 ubuntu+windows × node 22/24 并行,
|
|
18
|
+
// 计时断言不稳),锁的是「谁被物化」这个形状本身。
|
|
19
|
+
|
|
20
|
+
function make(over = {}) {
|
|
21
|
+
const store = createStore(":memory:");
|
|
22
|
+
return { store, config: { memoryQualityFilter: { enabled: true }, ...over } };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("#430: recentContents mirrors the all() prefix it replaced", () => {
|
|
26
|
+
const { store } = make();
|
|
27
|
+
for (let i = 0; i < 25; i += 1) {
|
|
28
|
+
store.save({ type: "project", title: `t${i}`, content: `c${i}`, importance: 3 });
|
|
29
|
+
}
|
|
30
|
+
// 旧写法:all().slice(0, 20).map((m) => m.content ?? "")。集合锁与逐位锁都要:
|
|
31
|
+
// 一旦把 ORDER BY 丢掉(或反向),只要 LIMIT 不低于表行数,取到的行一个不少、
|
|
32
|
+
// 只是顺序全反过来,集合锁抓不到这类回归(变异检验证实,见 PR 讨论)。
|
|
33
|
+
// 逐位比较在此安全:store.js 的 per-instance 单调时间戳守卫保证同一实例内
|
|
34
|
+
// updated_at 严格递增,故这几行的先后关系是确定的。
|
|
35
|
+
const expected = store.all().slice(0, 20).map((m) => m.content ?? "");
|
|
36
|
+
const actual = store.recentContents(20);
|
|
37
|
+
assert.equal(actual.length, 20);
|
|
38
|
+
assert.deepEqual(actual, expected, "same 20 contents in the same order as the old prefix");
|
|
39
|
+
assert.deepEqual([...actual].sort(), [...expected].sort(), "same 20 contents as the old prefix (order-agnostic backstop)");
|
|
40
|
+
assert.equal(store.recentContents().length, 20, "default limit is 20");
|
|
41
|
+
assert.equal(store.recentContents(3).length, 3);
|
|
42
|
+
// sanitizePage 口径:0 / 负数 / 非整数落回默认值,而不是 SQLite 里 LIMIT 无界
|
|
43
|
+
assert.equal(store.recentContents(0).length, 20, "limit 0 falls back to the default, never unbounded");
|
|
44
|
+
assert.equal(store.recentContents(-1).length, 20);
|
|
45
|
+
assert.equal(store.recentContents(undefined).length, 20);
|
|
46
|
+
// 口径与 all() 一致:不过滤 archived(近重复判定要连归档内容一起看)
|
|
47
|
+
const { id } = store.save({ type: "project", title: "旧", content: "归档正文", importance: 3 });
|
|
48
|
+
store.setArchived(id, true);
|
|
49
|
+
assert.ok(store.recentContents(20).includes("归档正文"), "archived rows still take part in near-duplicate detection");
|
|
50
|
+
// 空库不炸
|
|
51
|
+
const fresh = createStore(":memory:");
|
|
52
|
+
assert.deepEqual(fresh.recentContents(20), []);
|
|
53
|
+
store.close();
|
|
54
|
+
fresh.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("#430: list() projects columns and never leaks embedding", () => {
|
|
58
|
+
const { store } = make();
|
|
59
|
+
const { id } = store.save({ type: "project", title: "带向量", content: "正文", importance: 3 });
|
|
60
|
+
store.setEmbedding(id, [1, 0, 0]);
|
|
61
|
+
for (const opts of [{ limit: 50 }, { limit: null }, { order: "chrono", limit: 10 }, { type: "project", limit: null }]) {
|
|
62
|
+
const rows = store.list(opts);
|
|
63
|
+
assert.ok(rows.length > 0, `list(${JSON.stringify(opts)}) returned rows`);
|
|
64
|
+
for (const row of rows) {
|
|
65
|
+
assert.equal("embedding" in row, false, `embedding never leaks into list rows (${JSON.stringify(opts)})`);
|
|
66
|
+
assert.equal(typeof row.content, "string", "content still projected");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
store.close();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("#430: the write path reads explicit, bounded columns (no SELECT *, no embedding)", () => {
|
|
73
|
+
const { store } = make();
|
|
74
|
+
store.save({ type: "project", title: "带向量", content: "正文", importance: 3 });
|
|
75
|
+
// 语句级探针(不测墙钟):SELECT * 会把全表最重的 embedding 列拖进内存再被
|
|
76
|
+
// toRow 丢掉,且无界档实测 5k 行 263ms → 117ms。这里直接看发出去的 SQL——
|
|
77
|
+
// 将来谁把列投影改回 SELECT *(或手工把 embedding 加回清单),本锁立刻转红,
|
|
78
|
+
// 而「行里没有 embedding」那条锁抓不到这种回归(toRow 本就不输出向量)。
|
|
79
|
+
const seen = [];
|
|
80
|
+
const orig = store.db.prepare.bind(store.db);
|
|
81
|
+
store.db.prepare = (sql) => { seen.push(String(sql)); return orig(sql); };
|
|
82
|
+
const listSql = seen.length;
|
|
83
|
+
store.list({ limit: 50 });
|
|
84
|
+
store.list({ limit: null });
|
|
85
|
+
store.list({ type: "project", limit: 10, order: "chrono" });
|
|
86
|
+
const probed = seen.length - listSql;
|
|
87
|
+
assert.ok(probed >= 3, `expected three distinct list queries, saw ${probed}`);
|
|
88
|
+
for (const sql of seen) {
|
|
89
|
+
assert.ok(!/SELECT\s+\*/i.test(sql), `write path must project explicit columns: ${sql}`);
|
|
90
|
+
assert.ok(!/\bembedding\b/.test(sql), `write path must not read the embedding column: ${sql}`);
|
|
91
|
+
}
|
|
92
|
+
// 质量过滤那条单列读同样要探:它一旦退回 SELECT *,多读的正是全表最重的列。
|
|
93
|
+
seen.length = 0;
|
|
94
|
+
store.recentContents(20);
|
|
95
|
+
assert.equal(seen.length, 1, "recentContents issues exactly one query");
|
|
96
|
+
assert.ok(!/SELECT\s+\*/i.test(seen[0]), `recentContents must read one column: ${seen[0]}`);
|
|
97
|
+
assert.ok(!/\bembedding\b/.test(seen[0]), "recentContents must not read the embedding column");
|
|
98
|
+
assert.match(seen[0], /\bLIMIT\b/i, `recentContents must stay bounded: ${seen[0]}`);
|
|
99
|
+
store.close();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("#430: quality filter reads recent contents without materializing the table", () => {
|
|
103
|
+
const { store, config } = make();
|
|
104
|
+
// 抛错探针(先例 audit.test.js:182、mirror-dirty.test.js:383):写路径一旦再退回
|
|
105
|
+
// all(),本用例立刻失败,而不是等到某个 profile 上再被观测到。
|
|
106
|
+
store.all = () => { throw new Error("write path must not materialize the whole table"); };
|
|
107
|
+
const service = createService({ store, mirror: null, config });
|
|
108
|
+
const base = "今天下午去操场跑步三圈然后回来洗澡";
|
|
109
|
+
const first = service.saveWithDedupe({ type: "history", title: "记录A", content: base });
|
|
110
|
+
assert.equal(first.action, "created");
|
|
111
|
+
const second = service.saveWithDedupe({ type: "history", title: "记录B", content: `${base}${base}${base}` });
|
|
112
|
+
const row = store.getById(second.memory.id);
|
|
113
|
+
assert.equal(row.archived, true, "near-duplicate still archived");
|
|
114
|
+
assert.ok(row.tags.includes("low_quality"), "low_quality tag still added");
|
|
115
|
+
assert.ok(row.quality_score < 30, `score still below the archive threshold: ${row.quality_score}`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("#430: syncMirror covers the active set without materializing it", () => {
|
|
119
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-430-"));
|
|
120
|
+
try {
|
|
121
|
+
const { store, config } = make();
|
|
122
|
+
const mirror = createMirror(dir);
|
|
123
|
+
// 先按正常路径写一次,把镜像文件建起来
|
|
124
|
+
const seed = createService({ store, mirror, config });
|
|
125
|
+
const a = store.save({ type: "project", title: "活跃项目", content: "项目正文", importance: 3 });
|
|
126
|
+
store.save({ type: "preference", title: "偏好", content: "偏好正文", importance: 3 });
|
|
127
|
+
const hidden = store.save({ type: "project", title: "归档项目", content: "归档正文", importance: 3 });
|
|
128
|
+
store.setArchived(hidden.id, true);
|
|
129
|
+
const held = store.save({ type: "project", title: "遗忘项目", content: "遗忘正文", importance: 3 });
|
|
130
|
+
store.setForget(held.id, true);
|
|
131
|
+
seed.saveWithDedupe({ type: "project", title: "触发同步", content: "第一次触发同步的正文,长度足够不触发短内容规则", importance: 3 });
|
|
132
|
+
|
|
133
|
+
// 写路径不得再出现全表物化
|
|
134
|
+
store.all = () => { throw new Error("syncMirror must not materialize the whole table"); };
|
|
135
|
+
const svc = createService({ store, mirror, config });
|
|
136
|
+
const res = svc.saveWithDedupe({ type: "project", title: "第二次触发", content: "第二次触发同步的正文,内容与上一句不同以免被判近重复", importance: 3 });
|
|
137
|
+
assert.equal(res.action, "created");
|
|
138
|
+
assert.equal(res.memory.archived, false, "quality filter left it active (mirror only renders active rows)");
|
|
139
|
+
|
|
140
|
+
const text = readFileSync(join(dir, "projects.md"), "utf8");
|
|
141
|
+
assert.ok(text.includes(a.id), "active row still rendered");
|
|
142
|
+
assert.ok(text.includes(res.memory.id), "new row still rendered");
|
|
143
|
+
assert.ok(!text.includes("归档项目"), "archived row stays out");
|
|
144
|
+
assert.ok(!text.includes("遗忘项目"), "forgotten row stays out");
|
|
145
|
+
assert.ok(readFileSync(join(dir, "preferences.md"), "utf8").includes("偏好正文"), "sibling type rendered");
|
|
146
|
+
store.close();
|
|
147
|
+
} finally {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("#430: the document gate is decided in SQL, before anything is rendered", () => {
|
|
153
|
+
const dir = mkdtempSync(join(tmpdir(), "dsh-mneme-430-doc-"));
|
|
154
|
+
try {
|
|
155
|
+
const { store, config } = make({ memoryQualityFilter: { enabled: true }, documentMemoryEnabled: false });
|
|
156
|
+
const mirror = createMirror(dir);
|
|
157
|
+
const svc = createService({ store, mirror, config });
|
|
158
|
+
// 真 document 行:doc_path 只能经 store.saveDocument 铸造(通用 save 整类拒绝)。
|
|
159
|
+
// 这条用例必须让库里真的有 document 行——上一版用例库里一行都没有,
|
|
160
|
+
// 「闸关时不渲染 document」就无法与「根本没见过 document」区分(变异检验漏网)。
|
|
161
|
+
const doc = store.saveDocument({
|
|
162
|
+
type: "document",
|
|
163
|
+
title: "指针文档",
|
|
164
|
+
content: "文档摘要正文,长度足够",
|
|
165
|
+
doc_path: "docs/pointer.md",
|
|
166
|
+
importance: 3
|
|
167
|
+
});
|
|
168
|
+
svc.saveWithDedupe({ type: "project", title: "项目", content: "项目正文,长度足够绕开短内容归档规则", importance: 3 });
|
|
169
|
+
const docPath = mirror.filePath("document");
|
|
170
|
+
// 闸关(#296 第二批默认):有真指针行也不许渲染出 documents.md
|
|
171
|
+
assert.equal(existsSync(docPath), false, "documents.md stays absent while the gate is off");
|
|
172
|
+
// 陈旧视图必须被删掉:不留一个「看着还在、其实已关」的视图
|
|
173
|
+
writeFileSync(docPath, "陈旧视图", "utf8");
|
|
174
|
+
svc.saveWithDedupe({ type: "project", title: "再写一条", content: "再写一条正文,与上一条正文互不相似", importance: 3 });
|
|
175
|
+
assert.equal(existsSync(docPath), false, "stale documents.md pruned while the gate is off");
|
|
176
|
+
assert.equal(existsSync(join(dir, "index.md")), false, "index.md pruned while the gate is off");
|
|
177
|
+
assert.ok(readFileSync(join(dir, "projects.md"), "utf8").includes("项目正文"), "sibling type still rendered");
|
|
178
|
+
// 反向:闸开时同一份指针行确实会渲染(证明上面那条断言有判别力,而不是
|
|
179
|
+
// 因为 document 行从没进入过渲染集),关回去再必须删掉。
|
|
180
|
+
config.documentMemoryEnabled = true;
|
|
181
|
+
svc.saveWithDedupe({ type: "project", title: "闸开", content: "闸开后的正文,与前面几条互不相似", importance: 3 });
|
|
182
|
+
assert.ok(existsSync(docPath), "documents.md rendered while the gate is on");
|
|
183
|
+
assert.ok(readFileSync(docPath, "utf8").includes(doc.id), "pointer row is what gets rendered");
|
|
184
|
+
config.documentMemoryEnabled = false;
|
|
185
|
+
svc.saveWithDedupe({ type: "project", title: "闸关回去", content: "闸关回去后的正文,同样互不相似", importance: 3 });
|
|
186
|
+
assert.equal(existsSync(docPath), false, "documents.md pruned again once the gate flips back off");
|
|
187
|
+
store.close();
|
|
188
|
+
} finally {
|
|
189
|
+
rmSync(dir, { recursive: true, force: true });
|
|
190
|
+
}
|
|
191
|
+
});
|
package/test/summarize.test.js
CHANGED
|
@@ -3,6 +3,9 @@ import assert from "node:assert/strict";
|
|
|
3
3
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { Context } from "@deepseek-ai/cordis";
|
|
8
|
+
import * as mneme from "../src/index.js";
|
|
6
9
|
import { createStore } from "../src/store.js";
|
|
7
10
|
import { createService } from "../src/service.js";
|
|
8
11
|
import { createSummarizer, parseSummaryJson, parsePeakSpec, isInPeakWindow, nextOffPeakAt } from "../src/summarize.js";
|
|
@@ -1637,3 +1640,176 @@ test("issue#399: enabled=false (the default) writes nothing at all", async () =>
|
|
|
1637
1640
|
assert.equal(countRows(store, "SELECT count(*) AS c FROM memory_provenance"), 0);
|
|
1638
1641
|
assert.equal(store.count(), 2, "记忆写入照旧");
|
|
1639
1642
|
});
|
|
1643
|
+
// ── 蒸馏留存:开关从「设置面」一路走到「盘上有行」 ──────────────────────────────
|
|
1644
|
+
//
|
|
1645
|
+
// 上面 #399 那五条锁的是 summarize.js 内部的行为,它们把 config 直接递给
|
|
1646
|
+
// createSummarizer。真实链路上,config 不来自测试也不来自 bundle:它由 index.js
|
|
1647
|
+
// 在**启动时**把 kv(feature_flags,面板 PUT /api/dsh-mneme/features 写的就是它)
|
|
1648
|
+
// 合并进 cfg 得到,再交给 createSummarizer。中间两环——设置面写入与启动期
|
|
1649
|
+
// 合并——一条测试都没覆盖过,而它们正是「distill_records 行数开始增长」这条
|
|
1650
|
+
// 验收判据的唯一通路:开关没写进 kv,或者写进了但装配时没被读出来,盘上就永远
|
|
1651
|
+
// 是 0 行,而 summarize.js 内部的五条锁照样全绿。
|
|
1652
|
+
//
|
|
1653
|
+
// 所以这两条不在 createSummarizer 层造 config,而是起一个最小宿主(真实 cordis
|
|
1654
|
+
// Context + 六个服务位),让 ctx.plugin(mneme, …) 走完 index.js 的装配:
|
|
1655
|
+
// ① kv 写开 → 装配 → 派发 turn/end → 原文与溯源链接都落盘;
|
|
1656
|
+
// ② 同一个装配但 kv 一个键都不写(默认态)→ 两表 0 行,记忆写入照旧。
|
|
1657
|
+
// 第二条守的是反面:装配默认不许把 opt-in 的开关私自打开。
|
|
1658
|
+
|
|
1659
|
+
/** 最小宿主:六个服务位按 webServer-optional.test.js 的形状提供。 */
|
|
1660
|
+
function minimalHost() {
|
|
1661
|
+
const ctx = new Context();
|
|
1662
|
+
ctx.provide("tools", { register() { return () => {}; } });
|
|
1663
|
+
ctx.provide("commands", { register() { return () => {}; } });
|
|
1664
|
+
ctx.provide("systemPrompt", { context() { return () => {}; } });
|
|
1665
|
+
ctx.provide("agentDefaultModel", { currentSelection() { return { provider: "mock", model: "mock" }; } });
|
|
1666
|
+
ctx.provide("llm", {
|
|
1667
|
+
async *stream() {
|
|
1668
|
+
yield { type: "block-start", block: { type: "text" } };
|
|
1669
|
+
yield {
|
|
1670
|
+
type: "text-delta",
|
|
1671
|
+
delta: JSON.stringify([
|
|
1672
|
+
{ type: "decision", title: "选型", content: "确定用 node:sqlite", importance: 4 },
|
|
1673
|
+
{ type: "preference", title: "语言", content: "用户喜欢中文交流", importance: 5 }
|
|
1674
|
+
])
|
|
1675
|
+
};
|
|
1676
|
+
yield { type: "block-end", block: { type: "text" } };
|
|
1677
|
+
yield { type: "finish", kind: "ok" };
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
ctx.provide("webServer", { register() { return () => {}; } });
|
|
1681
|
+
return ctx;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
/** 盘上的三张表:装配前后各查一次,才知道行是谁写的。 */
|
|
1685
|
+
function readTables(dbPath) {
|
|
1686
|
+
const db = new DatabaseSync(dbPath);
|
|
1687
|
+
const count = (table) => db.prepare(`SELECT count(*) AS c FROM ${table}`).get().c;
|
|
1688
|
+
const out = {
|
|
1689
|
+
records: count("distill_records"),
|
|
1690
|
+
provenance: count("memory_provenance"),
|
|
1691
|
+
memories: count("memories")
|
|
1692
|
+
};
|
|
1693
|
+
out.rows = db.prepare("SELECT session_id, seq, role, text FROM distill_records ORDER BY seq").all();
|
|
1694
|
+
db.close();
|
|
1695
|
+
return out;
|
|
1696
|
+
}
|
|
1697
|
+
|
|
1698
|
+
/** 装配 + 派发 turn/end + 拆掉装配(不给宿主留开着的手柄)。 */
|
|
1699
|
+
async function bootAndDistill(config, session) {
|
|
1700
|
+
const ctx = minimalHost();
|
|
1701
|
+
const fiber = ctx.plugin(mneme, config);
|
|
1702
|
+
await fiber;
|
|
1703
|
+
// ctx.parallel 会等所有监听器跑完;summarize.js 的监听器 return 了这次写入的
|
|
1704
|
+
// promise(summarize.js 的 session/event 订阅处明写「Return the summarization
|
|
1705
|
+
// promise so awaiters observe the writes」),所以并行派发完就是写完了。
|
|
1706
|
+
await ctx.parallel("session/event", session, { seq: 2, type: "turn/end" });
|
|
1707
|
+
await fiber.dispose();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
test("distill records grow from the settings face all the way to disk", async () => {
|
|
1711
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-distill-on-"));
|
|
1712
|
+
const dbPath = join(dir, "memory.db");
|
|
1713
|
+
try {
|
|
1714
|
+
// 第一环:用产品自己的设置面写 kv,等价于面板 PUT /api/dsh-mneme/features。
|
|
1715
|
+
const seed = createStore(dbPath);
|
|
1716
|
+
const merged = createSettings(seed.db).setFeatureFlags({ "distillRecords.enabled": true });
|
|
1717
|
+
assert.equal(merged["distillRecords.enabled"], true, "开关必须先真的落进 kv");
|
|
1718
|
+
assert.equal(readTables(dbPath).records, 0, "开之前盘上一行都没有");
|
|
1719
|
+
seed.close();
|
|
1720
|
+
|
|
1721
|
+
// 第二环 + 第三环:装配时 bundle 说关、kv 说开,合并取 kv;然后真跑一次蒸馏。
|
|
1722
|
+
await bootAndDistill(
|
|
1723
|
+
{ memoryDir: dir, distillRecords: { enabled: false }, distillRateLimitIntervalMs: 0 },
|
|
1724
|
+
recordsSession("s-distill-on", [userMessage("这句话该落进 distill_records", 1), { seq: 2, type: "turn/end" }])
|
|
1725
|
+
);
|
|
1726
|
+
|
|
1727
|
+
const after = readTables(dbPath);
|
|
1728
|
+
assert.equal(after.records, 1, "装配读过 kv 之后,原文必须真的落盘");
|
|
1729
|
+
assert.equal(after.rows[0].session_id, "s-distill-on");
|
|
1730
|
+
assert.equal(after.rows[0].seq, 1);
|
|
1731
|
+
assert.equal(after.rows[0].role, "user");
|
|
1732
|
+
assert.match(after.rows[0].text, /该落进 distill_records/, "落盘的是公开正文原文");
|
|
1733
|
+
assert.equal(after.memories, 2, "记忆照常写入,留存是叠加不是替换");
|
|
1734
|
+
assert.equal(after.provenance, 2, "每条新记忆各落一条回指链接");
|
|
1735
|
+
} finally {
|
|
1736
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1737
|
+
}
|
|
1738
|
+
});
|
|
1739
|
+
|
|
1740
|
+
test("the same assembly writes nothing when the flag was never set", async () => {
|
|
1741
|
+
const dir = mkdtempSync(join(tmpdir(), "mneme-distill-off-"));
|
|
1742
|
+
const dbPath = join(dir, "memory.db");
|
|
1743
|
+
try {
|
|
1744
|
+
// 连 createStore 都不预先起:一个全新的库就是「从没碰过设置面」的真实形态。
|
|
1745
|
+
await bootAndDistill(
|
|
1746
|
+
{ memoryDir: dir, distillRateLimitIntervalMs: 0 },
|
|
1747
|
+
recordsSession("s-distill-off", [userMessage("默认态:不该有原文落盘", 1), { seq: 2, type: "turn/end" }])
|
|
1748
|
+
);
|
|
1749
|
+
|
|
1750
|
+
const after = readTables(dbPath);
|
|
1751
|
+
assert.equal(after.records, 0, "opt-in 的开关不许被装配私自打开");
|
|
1752
|
+
assert.equal(after.provenance, 0);
|
|
1753
|
+
assert.equal(after.memories, 2, "关的只是留存,蒸馏本身照常");
|
|
1754
|
+
} finally {
|
|
1755
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
|
|
1759
|
+
|
|
1760
|
+
// v0.9.0(M.O. 项目记忆):机器提炼是写入量的绝大多数,而它此前一个作用域字段都不带
|
|
1761
|
+
// ——全库带 scope 的行不足 3%,且全部来自 memory_save 那条工具路径。这组用例钉住
|
|
1762
|
+
// 「蒸馏写入也盖章」这件事:值取 session.header(agentPreset + cwd),闸门与工具路径
|
|
1763
|
+
// 同一把(scopeEnabled),来源一律 'auto'(auto 不构成硬墙,只吃软加权,见
|
|
1764
|
+
// test/scope-strict.test.js 的 "auto and legacy rows are NOT hard walls" 用例,
|
|
1765
|
+
// 2026-10-13 在 :41-54)。
|
|
1766
|
+
function scopedSession(id, header) {
|
|
1767
|
+
const session = {
|
|
1768
|
+
id,
|
|
1769
|
+
requestHeader: () => ({ config: { provider: "deepseek", model: "deepseek-chat" } }),
|
|
1770
|
+
events: [userMessage("继续"), { seq: 2, type: "turn/end" }]
|
|
1771
|
+
};
|
|
1772
|
+
if (header !== undefined) session.header = header;
|
|
1773
|
+
return session;
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
test("v0.9.0: scopeEnabled 开时蒸馏写入盖 auto 章(agent=agentPreset、workspace=header.cwd)", async () => {
|
|
1777
|
+
const { events, store } = setup({ scopeEnabled: true });
|
|
1778
|
+
const session = scopedSession("mo-scope", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" });
|
|
1779
|
+
await eventHandler(events)(session, { seq: 2, type: "turn/end" });
|
|
1780
|
+
|
|
1781
|
+
const rows = store.list({ limit: null });
|
|
1782
|
+
assert.equal(rows.length, 2, "两个条目都落库");
|
|
1783
|
+
for (const row of rows) {
|
|
1784
|
+
assert.equal(row.agent_scope, "amarre", "agent 维取自 session.header.agentPreset");
|
|
1785
|
+
assert.equal(row.agent_scope_source, "auto", "来源必须标 auto:显式声明才会构成硬过滤");
|
|
1786
|
+
assert.equal(row.workspace_scope, "C:\\work\\dsh-mneme", "workspace 维取自 session.header.cwd");
|
|
1787
|
+
assert.equal(row.workspace_scope_source, "auto");
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
|
|
1791
|
+
test("v0.9.0: 解析不出身份时照样标 auto、值落 NULL(与「从未标注」可区分)", async () => {
|
|
1792
|
+
const { events, store } = setup({ scopeEnabled: true });
|
|
1793
|
+
// 无 header、无 workspaceRegistry(mock ctx 只有 on/llm):正是老会话/测试环境的形状。
|
|
1794
|
+
await eventHandler(events)(scopedSession("mo-scope-anon"), { seq: 2, type: "turn/end" });
|
|
1795
|
+
|
|
1796
|
+
for (const row of store.list({ limit: null })) {
|
|
1797
|
+
assert.equal(row.agent_scope, undefined);
|
|
1798
|
+
assert.equal(row.workspace_scope, undefined);
|
|
1799
|
+
// 关键区别:*_source='auto' 说明「自动标注跑过但没解析出值」,NULL 值才等于全局可见。
|
|
1800
|
+
assert.equal(row.agent_scope_source, "auto");
|
|
1801
|
+
assert.equal(row.workspace_scope_source, "auto");
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
|
|
1805
|
+
test("v0.9.0: scopeEnabled 关时蒸馏写入一个 scope 字段都不加(老行为不变)", async () => {
|
|
1806
|
+
const { events, store } = setup({});
|
|
1807
|
+
await eventHandler(events)(scopedSession("mo-scope-off", { agentPreset: "amarre", cwd: "C:\\work\\dsh-mneme" }), { seq: 2, type: "turn/end" });
|
|
1808
|
+
|
|
1809
|
+
for (const row of store.list({ limit: null })) {
|
|
1810
|
+
assert.equal(row.agent_scope, undefined);
|
|
1811
|
+
assert.equal(row.agent_scope_source, undefined);
|
|
1812
|
+
assert.equal(row.workspace_scope, undefined);
|
|
1813
|
+
assert.equal(row.workspace_scope_source, undefined);
|
|
1814
|
+
}
|
|
1815
|
+
});
|
|
@@ -337,10 +337,10 @@ test("supersede 后注入语义候选池(向量命中 / 语义缓存)不得
|
|
|
337
337
|
const { store, service, legacy, current } = await supersededFixture({ embedder: semanticEmbedder() });
|
|
338
338
|
service.setVectorIndex(leakyVectorIndex(store));
|
|
339
339
|
|
|
340
|
-
// 先填一次语义缓存:新查询的首帧 render 没有 queryVector(src/inject.js
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
340
|
+
// 先填一次语义缓存:新查询的首帧 render 没有 queryVector(src/inject.js 的
|
|
341
|
+
// prefetchQueryVector 预取是异步 .then,2026-10-14 在 :296),只能吃
|
|
342
|
+
// lastSemanticRecall。真实检索不会吐归档行,是上面的假索引在源侧失守。填缓存
|
|
343
|
+
// 的这一刻已经是 supersede 之后,所以缓存条目里 loser 的 archived 是 true。
|
|
344
344
|
const cached = await service.searchMemories(QUERY, { mode: "vector", useRerank: false, recordRecall: false });
|
|
345
345
|
const cachedOld = cached.find((r) => r.id === legacy.id);
|
|
346
346
|
// 夹具自检:确认缓存里确实躺着带 archived 标记的旧值,否则 (e) 是空压。
|
package/test/tools.test.js
CHANGED
|
@@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
|
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { createTools } from "../src/tools.js";
|
|
10
10
|
import { TRANSFORMERS_ENTRY, defaultModelCacheDir, payloadDir, payloadId } from "../src/runtime/layout.js";
|
|
11
|
+
import { hostModulesDir } from "../src/runtime/provision.js";
|
|
11
12
|
|
|
12
13
|
function setup(embedder, config = {}) {
|
|
13
14
|
const store = createStore(":memory:");
|
|
@@ -370,7 +371,21 @@ test("memory_runtime:status 只读且带代价说明;两条来源都不可
|
|
|
370
371
|
const provision = await tool.execute({ action: "provision" });
|
|
371
372
|
assert.deepEqual(validateJsonSchemaValue(tool.output.schema, provision), []);
|
|
372
373
|
assert.equal(provision.status, "failed");
|
|
373
|
-
|
|
374
|
+
// #323:车道(junction node_modules)里这条用例报过一次红,红的是下面那句「半份 payload」,
|
|
375
|
+
// 但变的是**环境**不是产品。所以把这条用例赖以成立的前提显式钉在这里:
|
|
376
|
+
// hostModulesDir 是 import.meta.url 的 4 级上溯,只有装入布局
|
|
377
|
+
// (<profile>/node_modules/@modusensus/dsh-mneme/src/tools.js → <profile>/node_modules)
|
|
378
|
+
// 才指向真的 node_modules;仓库根与车道布局下它落到仓库父目录 / <repo>\.worktrees,
|
|
379
|
+
// 于是收编在**计划阶段**就因「入口包不存在」零写入地失败——根本没走到物化分支,残留无从产生。
|
|
380
|
+
// (「物化到一半要清干净」由 runtime-adopt.test.js / runtime-download.test.js 的注入式用例负责,不靠这里。)
|
|
381
|
+
// 若有人改了目录层级、让源目录变成真的 node_modules,这一条会先红并把推导出的路径打出来,
|
|
382
|
+
// 而不是让「半份 payload」背一个看不懂的锅。
|
|
383
|
+
const source = hostModulesDir(new URL("../src/tools.js", import.meta.url).href);
|
|
384
|
+
assert.match(
|
|
385
|
+
provision.reason,
|
|
386
|
+
/收编失败:入口包不存在/,
|
|
387
|
+
`本用例的前提是「${source} 里没有可收编的 payload」;源目录换了形状,收编就会真的走物化分支(issue #323)`
|
|
388
|
+
);
|
|
374
389
|
assert.equal(existsSync(runtimeDir) ? readdirSync(runtimeDir).length : 0, 0, "失败后不该留下半份 payload");
|
|
375
390
|
});
|
|
376
391
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// 工作流配置的回归锁:安全扫描、发布权限、发版准备的注入面不能悄悄退回。
|
|
2
|
+
// 锁的都是「配置漂移」——CI 配置没有类型检查,改错了照样绿。
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { readFileSync } from "node:fs";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
const repoRoot = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
|
|
10
|
+
|
|
11
|
+
test("security.yml 的 osv-scanner 保持 fail-on-vuln: true", () => {
|
|
12
|
+
// 2026-10 审计:这里原本是 false(纯提醒,PR 上永远是绿的)。改回 false 就等于
|
|
13
|
+
// 「有已知漏洞也不红」,而测试目录原本没有任何东西会察觉这件事。
|
|
14
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "security.yml"), "utf8");
|
|
15
|
+
assert.match(yml, /fail-on-vuln:\s*true/, "osv-scanner 必须保持 fail-on-vuln: true");
|
|
16
|
+
assert.doesNotMatch(yml, /fail-on-vuln:\s*false/, "不得退回只上传 SARIF 不拦的档位");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// 剥掉整行注释再断言:注释里会**描述**「id-token: write 该发给谁」,
|
|
20
|
+
// 直接对全文 grep 会被自己写的说明文字骗过(第一版就栽在这里)。
|
|
21
|
+
function codeOnly(yml) {
|
|
22
|
+
return yml.split(/\r?\n/).filter((l) => !/^\s*#/.test(l)).join("\n");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 按缩进切出单个 job 的文本块:工作流顶层是 0 缩进,job 名是 2 缩进。
|
|
26
|
+
// 只做文本切片不做完整 YAML 解析——仓库零依赖,为一条断言引 yaml 解析器不划算。
|
|
27
|
+
function jobBlock(yml, name) {
|
|
28
|
+
const lines = codeOnly(yml).split(/\r?\n/);
|
|
29
|
+
const start = lines.findIndex((l) => l === " " + name + ":");
|
|
30
|
+
assert.ok(start >= 0, "找不到 job: " + name);
|
|
31
|
+
let end = lines.length;
|
|
32
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
33
|
+
if (/^ [A-Za-z0-9_-]+:$/.test(lines[i])) { end = i; break; }
|
|
34
|
+
}
|
|
35
|
+
return lines.slice(start, end).join("\n");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("release.yml 的 provenance 权限只发给 publish,不发给工作流顶层", () => {
|
|
39
|
+
// 三件事分开断言,因为「全文件 grep id-token: write」会被两种错误都骗过:
|
|
40
|
+
// ①有人把 id-token 提到工作流顶层(verify/test 也拿到 OIDC 能力)
|
|
41
|
+
// ②有人把 publish 的权限删了但别的 job 还有(provenance 会失败)
|
|
42
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release.yml"), "utf8");
|
|
43
|
+
assert.match(yml, /npm publish[^\n]*--provenance/, "publish 必须带 --provenance");
|
|
44
|
+
|
|
45
|
+
// YAML 允许把顶层键写成 "jobs": 或 'jobs':,那也是合法的工作流。
|
|
46
|
+
// 只匹配裸 jobs: 会在那种写法下把整个文件当成 head,于是 publish 里合法的
|
|
47
|
+
// id-token 被误判成「顶层声明」——失败方向是误红不是漏检,但假红会磨损这条锁。
|
|
48
|
+
const head = codeOnly(yml).split(/\r?\n(?=["']?jobs["']?:\s*$)/m)[0];
|
|
49
|
+
assert.doesNotMatch(head, /id-token:/, "工作流顶层不得声明 id-token(会泄漏给所有 job)");
|
|
50
|
+
|
|
51
|
+
const publish = jobBlock(yml, "publish");
|
|
52
|
+
assert.match(publish, /id-token:\s*write/, "publish job 需要 id-token: write");
|
|
53
|
+
assert.doesNotMatch(publish, /contents:\s*write/, "publish 只读代码,不需要 contents: write");
|
|
54
|
+
|
|
55
|
+
const release = jobBlock(yml, "release");
|
|
56
|
+
assert.match(release, /contents:\s*write/, "release job 建 Release 需要 contents: write");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// 把每个 `run: |` 块的正文切出来。要断言的是「脚本正文里没有模板展开」,
|
|
60
|
+
// 而 run 块之外(env、with、name)出现 ${{ }} 是正常且必需的。
|
|
61
|
+
function runBlocks(yml) {
|
|
62
|
+
const lines = codeOnly(yml).split(/\r?\n/);
|
|
63
|
+
const blocks = [];
|
|
64
|
+
for (let i = 0; i < lines.length; i++) {
|
|
65
|
+
const m = /^(\s*)run:\s*\|\s*$/.exec(lines[i]);
|
|
66
|
+
if (!m) continue;
|
|
67
|
+
const indent = m[1].length;
|
|
68
|
+
let j = i + 1;
|
|
69
|
+
for (; j < lines.length; j++) {
|
|
70
|
+
const line = lines[j];
|
|
71
|
+
if (!line.trim()) continue;
|
|
72
|
+
if (line.match(/^\s*/)[0].length <= indent) break;
|
|
73
|
+
}
|
|
74
|
+
blocks.push(lines.slice(i + 1, j).join("\n"));
|
|
75
|
+
i = j - 1;
|
|
76
|
+
}
|
|
77
|
+
return blocks;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
test("release-prep.yml 的版本号走 env 注入,不内插进 run 脚本", () => {
|
|
81
|
+
// 2026-10 审计:zizmor 对 `--head "release-prep/${{ inputs.version }}"` 报 template-injection
|
|
82
|
+
// (模板展开可能带进攻击者可控的代码)。虽然上游 Validate 已用 ^[0-9]+\.[0-9]+\.[0-9]+$ 卡过格式,
|
|
83
|
+
// 但这里让扫描器看清「值不经过 shell 重解析」比让它推理上游校验够不够严便宜得多。
|
|
84
|
+
// 退回内插不会有任何测试变红,所以在这里锁住。
|
|
85
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
|
|
86
|
+
assert.match(yml, /VERSION:\s*\$\{\{\s*inputs\.version\s*\}\}/, "版本号必须先进 env 再进脚本");
|
|
87
|
+
const blocks = runBlocks(yml);
|
|
88
|
+
assert.ok(blocks.length >= 3, "至少应切出 Validate / Count tests / Create PR 三个 run 块,实际 " + blocks.length);
|
|
89
|
+
for (const block of blocks) {
|
|
90
|
+
assert.doesNotMatch(block, /\$\{\{/, "run 脚本正文里不得出现模板展开:" + block.slice(0, 120));
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("release-prep.yml 与 badge:sync 用同一个记账口径(套件总数,不是通过数)", () => {
|
|
95
|
+
// v0.8.11 徽章漂移的同类失效:两条路径各有一套口径时,谁都不知道该信哪个数。
|
|
96
|
+
// 徽章报的是 CI(ubuntu)口径——平台条件跳过的用例在 pass 里不计、在 tests 里计入,
|
|
97
|
+
// 于是从一台有跳过条件的机器上跑发版准备,会写进一个比 badge:sync 小 1 的徽章,
|
|
98
|
+
// 而且它进 PR 时 CI 是绿的(徽章只出现在文档里,没有测试会去比对它)。
|
|
99
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
|
|
100
|
+
const blocks = runBlocks(yml);
|
|
101
|
+
const countBlock = blocks.find((b) => /node --test/.test(b));
|
|
102
|
+
assert.ok(countBlock, "应当能切出跑测试的那个 run 块");
|
|
103
|
+
assert.ok(countBlock.includes("tests [0-9]+"), "计数必须取 tests N(与 sync-test-badge.mjs 同口径)");
|
|
104
|
+
assert.ok(!countBlock.includes("pass [0-9]+"), "不得退回 pass N:那是另一套记账口径");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("发版准备的两份人写清单都点出跨 minor 要挪 SECURITY.md 支持版本表", () => {
|
|
108
|
+
// 跨 minor 时 security-doc.test.js 会红,而那时人往往已经忘了这张表——工作流开的 PR
|
|
109
|
+
// 就是最后一道能把话说清楚的时机。commit-msg 与 pr-body 是两份独立的文本,
|
|
110
|
+
// 少一份就等于一半的路径上没有提示(reviewer 只看 PR 正文,git log 只看提交信息)。
|
|
111
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
|
|
112
|
+
const hits = yml.match(/SECURITY\.md 中英两张支持版本表/g) || [];
|
|
113
|
+
assert.equal(hits.length, 2, "commit message 与 PR 正文各要有一条,实际 " + hits.length);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("release-prep.yml 的重跑守卫:已有 PR 不动分支,只覆盖机器产出", () => {
|
|
117
|
+
// 2026-10-18 实测:工作流 8 步里前 7 步全成功,最后一步 git push 被
|
|
118
|
+
// "! [rejected] release-prep/0.9.1 -> release-prep/0.9.1 (fetch first)" 打回——
|
|
119
|
+
// 上一次运行已经把同名分支推上去了。不可重入的代价不是「重跑失败」这么轻:
|
|
120
|
+
// 人会以为流程坏了,转去手工发版,整套自动化就此被绕开。
|
|
121
|
+
//
|
|
122
|
+
// 但「能重跑」不能退化成「无条件 force push」:分支上可能已经有人补过 CHANGELOG
|
|
123
|
+
// 条目,那正是这套流程留给人的那部分工作,覆盖掉比不能重跑更糟。所以守卫分三档:
|
|
124
|
+
// 有进行中的 PR → 什么都不动;顶端是机器产出且没有 PR → 按重跑覆盖;其他 → 停下要人看。
|
|
125
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
|
|
126
|
+
const blocks = runBlocks(yml);
|
|
127
|
+
const pushBlock = blocks.find((b) => /git push/.test(b));
|
|
128
|
+
assert.ok(pushBlock, "应当能切出含 git push 的那个 run 块");
|
|
129
|
+
|
|
130
|
+
const prLookup = pushBlock.indexOf('--state open');
|
|
131
|
+
const pushAt = pushBlock.indexOf("git push");
|
|
132
|
+
assert.ok(prLookup > 0, "重跑前必须查有没有进行中的 PR(gh pr list --state open)");
|
|
133
|
+
assert.ok(prLookup < pushAt, "查 PR 必须发生在 push 之前");
|
|
134
|
+
|
|
135
|
+
// 三档判定:没有 PR 才继续;继续时顶端提交必须是本工作流生成的。
|
|
136
|
+
assert.ok(pushBlock.includes("--force-with-lease="), "重跑覆盖必须用 --force-with-lease(锁住 fetch 到的 SHA)");
|
|
137
|
+
assert.ok(!/git push[^\n]*--force(?!-with-lease)/.test(pushBlock), "不得退回无条件 --force");
|
|
138
|
+
// 断言的是那个 if 本身,不是变量名:变量名留在 case 里、判定被删掉,
|
|
139
|
+
// 只查 includes("MACHINE_TAIL") 照样绿(第一版就是这么漏的)。
|
|
140
|
+
assert.match(pushBlock, /if \[ "\$MACHINE_TAIL" != "yes" \]; then/, "必须有「顶端不是机器产出就停下」的那道判定");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("release-prep.yml 开 PR 失败时给的是可行动的报错,不是裸 GraphQL 报错", () => {
|
|
144
|
+
// 第二个真因(2026-10-18 实测):仓库级开关 can_approve_pull_request_reviews=false,
|
|
145
|
+
// job 里写 pull-requests: write 覆盖不了它,最后一步报
|
|
146
|
+
// "GitHub Actions is not permitted to create or approve pull requests"。
|
|
147
|
+
// 这个开关在**组织层**默认关着时仓库层根本改不动(gh api 返回 409 Conflict),
|
|
148
|
+
// 所以要让人一眼看出该去哪里开、开完能不能直接重跑。
|
|
149
|
+
const yml = readFileSync(path.join(repoRoot, ".github", "workflows", "release-prep.yml"), "utf8");
|
|
150
|
+
// 只在**可执行部分**里找:这几句话在注释里也出现过(注释是在解释为什么),
|
|
151
|
+
// 对全文 grep 会被自己写的说明文字骗过——报错文案被删干净了照样绿。
|
|
152
|
+
const code = codeOnly(yml);
|
|
153
|
+
assert.match(code, /not permitted to create or approve pull requests/, "报错文案要点名这个具体的 GitHub 报错,人才搜得到");
|
|
154
|
+
assert.match(code, /Settings → Actions → General/, "必须写清去哪个设置页开");
|
|
155
|
+
assert.match(code, /Allow GitHub Actions to create and approve pull requests/, "必须写清要打开的那个开关叫什么");
|
|
156
|
+
});
|