dsh-lite-memory 0.18.43 → 0.18.44
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/docs/BENCH.md +14 -0
- package/docs/RELEASE-0.18.44.md +21 -0
- package/package.json +3 -3
- package/test/anchor.test.mjs +0 -795
- package/test/client.test.mjs +0 -400
- package/test/contract.test.mjs +0 -1342
- package/test/control.test.mjs +0 -324
- package/test/crossing.test.mjs +0 -471
- package/test/eval-seed.test.mjs +0 -113
- package/test/evolution-read.test.mjs +0 -658
- package/test/fixtures/legacy-v1/archive/mem_444444444444.json +0 -18
- package/test/fixtures/legacy-v1/entries/mem_111111111111.md +0 -2
- package/test/fixtures/legacy-v1/entries/mem_222222222222.md +0 -1
- package/test/fixtures/legacy-v1/entries/mem_333333333333.md +0 -1
- package/test/fixtures/legacy-v1/index.json +0 -63
- package/test/ha.test.mjs +0 -186
- package/test/layer-read.test.mjs +0 -293
- package/test/learning.test.mjs +0 -1450
- package/test/lexical.test.mjs +0 -68
- package/test/palace.test.mjs +0 -104
- package/test/recall.test.mjs +0 -1039
- package/test/record.test.mjs +0 -130
- package/test/resident.test.mjs +0 -46
- package/test/runtime-context.test.mjs +0 -192
- package/test/safety.test.mjs +0 -119
- package/test/scenario.test.mjs +0 -184
- package/test/state.test.mjs +0 -663
- package/test/store.test.mjs +0 -1487
- package/test/view.test.mjs +0 -264
package/test/store.test.mjs
DELETED
|
@@ -1,1487 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import os from 'node:os';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
process.env.DSH_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lite-memory-'));
|
|
7
|
-
const store = await import('../store.js');
|
|
8
|
-
const state = await import('../state.js');
|
|
9
|
-
|
|
10
|
-
const { upsert, search, readEntry, forget, undoChange, acceptCandidate, rejectCandidate,
|
|
11
|
-
suggestionGate, noteSuggestion, findSuggestion, renderIndex, normalizeDraft, loadIndex,
|
|
12
|
-
scopeOf, globalScope, LIMITS, recordCitations, listCitations, readCitationBody,
|
|
13
|
-
evolutionStatus, setEvolution, saveIndex, readCitationBodyByTitle } = store;
|
|
14
|
-
|
|
15
|
-
let passed = 0;
|
|
16
|
-
const test = (name, fn) => {
|
|
17
|
-
try {
|
|
18
|
-
fn();
|
|
19
|
-
passed += 1;
|
|
20
|
-
} catch (error) {
|
|
21
|
-
console.error(`FAIL ${name}`);
|
|
22
|
-
throw error;
|
|
23
|
-
}
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const dirs = [];
|
|
27
|
-
const fresh = (label) => {
|
|
28
|
-
const dir = path.join(os.tmpdir(), `lite-memory-case-${label}-${Math.random().toString(16).slice(2, 8)}`);
|
|
29
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
-
dirs.push(dir);
|
|
31
|
-
return scopeOf(dir);
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
const stateOf = (scope) => JSON.parse(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8')).payload;
|
|
35
|
-
const bodyOf = (scope, id) => {
|
|
36
|
-
const entry = stateOf(scope).entries.find((row) => row.id === id);
|
|
37
|
-
return entry ? entry.current.body : undefined;
|
|
38
|
-
};
|
|
39
|
-
const rawState = (scope) => fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
|
|
40
|
-
const countOf = (scope) => loadIndex(scope).entries.length;
|
|
41
|
-
const suggestionsOf = (scope) => loadIndex(scope).suggestions;
|
|
42
|
-
|
|
43
|
-
test('over-specific body is stored, not rejected for naming concrete values', () => {
|
|
44
|
-
const scope = fresh('specific');
|
|
45
|
-
const saved = upsert(scope, {
|
|
46
|
-
title: '自绘菜单',
|
|
47
|
-
summary: '菜单用应用自绘,不用系统原生',
|
|
48
|
-
body: '菜单 z-index: 1100;宽度 min(264px, 100vw - 24px);字号 12px;内边距 10px;圆角 8px。',
|
|
49
|
-
});
|
|
50
|
-
assert.equal(saved.record.title, '自绘菜单');
|
|
51
|
-
assert.ok(saved.record.body.includes('z-index'));
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('a 500-character body is accepted', () => {
|
|
55
|
-
const scope = fresh('toolong');
|
|
56
|
-
const saved = upsert(scope, { title: '长正文', summary: '长一点也要存下来', body: '记'.repeat(500) });
|
|
57
|
-
assert.equal(saved.record.title, '长正文');
|
|
58
|
-
assert.equal(saved.record.body.length, 500);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
test('正文正好到 4096 字节可以存,元数据不算进正文上限', () => {
|
|
62
|
-
const scope = fresh('body-cap');
|
|
63
|
-
const body = `${'测'.repeat(1365)}a`;
|
|
64
|
-
const saved = upsert(scope, { title: '正文到顶', summary: '正文刚好到上限', body });
|
|
65
|
-
assert.equal(saved.record.body, body);
|
|
66
|
-
assert.ok(saved.record.bytes > LIMITS.entryBytes);
|
|
67
|
-
assert.throws(() => upsert(scope, { title: '超一点', summary: '正文超一点', body: `${body}a` }), /正文 4097 字节/);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('create then same title merges with full old body kept', () => {
|
|
71
|
-
const scope = fresh('create');
|
|
72
|
-
const first = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试' });
|
|
73
|
-
assert.equal(first.merged, false);
|
|
74
|
-
assert.equal(first.needsResolution, false);
|
|
75
|
-
assert.equal(first.record.revision, 1);
|
|
76
|
-
const second = upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: '第一步\n- 跑测试\n- 改版本号' });
|
|
77
|
-
assert.equal(second.merged, true);
|
|
78
|
-
assert.equal(second.record.id, first.record.id);
|
|
79
|
-
assert.equal(second.record.revision, 2);
|
|
80
|
-
assert.equal(second.record.versions.at(-1).body, '第一步\n- 跑测试');
|
|
81
|
-
assert.equal(second.record.versions.at(-1).summary, '发版前要按顺序做的事');
|
|
82
|
-
assert.match(readEntry(scope, first.record.id).body, /改版本号/);
|
|
83
|
-
assert.equal(countOf(scope), 1);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('update replaces the body instead of merging markdown sections', () => {
|
|
87
|
-
const scope = fresh('replace');
|
|
88
|
-
const first = upsert(scope, { title: '发布顺序', summary: '发版前按顺序做的事', body: '## 部署\ndeploy 命令' });
|
|
89
|
-
const next = upsert(scope, { id: first.record.id, title: '发布顺序', summary: '发版前按顺序做的事', body: '## 回滚\nrollback 命令' });
|
|
90
|
-
assert.equal(next.merged, true);
|
|
91
|
-
const body = readEntry(scope, first.record.id).body;
|
|
92
|
-
assert.equal(body, '## 回滚\nrollback 命令');
|
|
93
|
-
assert.equal(next.record.versions.at(-1).body, '## 部署\ndeploy 命令');
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test('update without when/kind/tags/body keeps the old values', () => {
|
|
97
|
-
const scope = fresh('patch');
|
|
98
|
-
const first = upsert(scope, {
|
|
99
|
-
title: '回复用中文',
|
|
100
|
-
summary: '用户要求回复用中文',
|
|
101
|
-
when: '所有对话',
|
|
102
|
-
kind: 'preference',
|
|
103
|
-
tags: ['语言'],
|
|
104
|
-
body: '用中文回复。',
|
|
105
|
-
});
|
|
106
|
-
const next = upsert(scope, { id: first.record.id, title: '回复用中文', summary: '用户要求一律用中文回复' });
|
|
107
|
-
assert.equal(next.record.when, '所有对话');
|
|
108
|
-
assert.equal(next.record.kind, 'preference');
|
|
109
|
-
assert.deepEqual(next.record.tags, ['语言']);
|
|
110
|
-
assert.equal(readEntry(scope, first.record.id).body, '用中文回复。');
|
|
111
|
-
const cleared = upsert(scope, { id: first.record.id, title: '回复用中文', summary: '用户要求一律用中文回复', when: '', tags: [] });
|
|
112
|
-
assert.equal(cleared.record.when, '');
|
|
113
|
-
assert.deepEqual(cleared.record.tags, []);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('similar title without id returns resolution and writes nothing', () => {
|
|
117
|
-
const scope = fresh('similar');
|
|
118
|
-
const base = upsert(scope, { title: 'npm 与 GitHub 发布凭据位置', summary: '两处凭据各在哪取', body: 'keychain 与 gh auth' });
|
|
119
|
-
const before = countOf(scope);
|
|
120
|
-
const entriesBefore = rawState(scope);
|
|
121
|
-
const conflict = upsert(scope, { title: 'npm 与 GitHub 发布凭据在哪', summary: '两处凭据各在哪取', body: '另一条' });
|
|
122
|
-
assert.equal(conflict.needsResolution, true);
|
|
123
|
-
assert.equal(conflict.record, null);
|
|
124
|
-
assert.ok(conflict.similarList.length >= 1 && conflict.similarList.length <= 2);
|
|
125
|
-
assert.equal(conflict.similarList[0].id, base.record.id);
|
|
126
|
-
assert.equal(countOf(scope), before);
|
|
127
|
-
assert.equal(rawState(scope), entriesBefore);
|
|
128
|
-
const distinct = upsert(scope, { title: 'npm 与 GitHub 发布凭据在哪', summary: '两处凭据各在哪取', body: '另一条', resolution: 'distinct' });
|
|
129
|
-
assert.equal(distinct.needsResolution, false);
|
|
130
|
-
assert.equal(distinct.merged, false);
|
|
131
|
-
assert.equal(countOf(scope), before + 1);
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
test('same title with a different when is a second entry', () => {
|
|
135
|
-
const scope = fresh('when');
|
|
136
|
-
const a = upsert(scope, { title: '跑测试', summary: '提交前跑哪条命令', when: '本地', body: 'node test' });
|
|
137
|
-
const b = upsert(scope, { title: '跑测试', summary: '提交前跑哪条命令', when: 'CI', body: 'pnpm test' });
|
|
138
|
-
assert.notEqual(a.record.id, b.record.id);
|
|
139
|
-
assert.equal(countOf(scope), 2);
|
|
140
|
-
const again = upsert(scope, { title: '跑测试', summary: '提交前跑哪条命令', when: 'CI', body: 'pnpm test --watch' });
|
|
141
|
-
assert.equal(again.merged, true);
|
|
142
|
-
assert.equal(again.record.id, b.record.id);
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
test('expired or superseded records do not block a new same-title save', () => {
|
|
146
|
-
const scope = fresh('stale-merge');
|
|
147
|
-
const first = upsert(scope, { title: '旧结论', summary: '这条已经过期', topicKey: 'old-topic', body: '旧' });
|
|
148
|
-
const expired = loadIndex(scope);
|
|
149
|
-
expired.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
|
|
150
|
-
saveIndex(scope, expired);
|
|
151
|
-
const next = upsert(scope, { title: '旧结论', summary: '新结论应该另存', topicKey: 'old-topic', body: '新' });
|
|
152
|
-
assert.equal(next.needsResolution, false);
|
|
153
|
-
assert.equal(next.merged, false);
|
|
154
|
-
assert.notEqual(next.record.id, first.record.id);
|
|
155
|
-
|
|
156
|
-
const second = upsert(scope, { title: '被取代的结论', summary: '已经被新版本取代', body: '旧' });
|
|
157
|
-
const superseded = loadIndex(scope);
|
|
158
|
-
superseded.entries.find((row) => row.id === second.record.id).supersededBy = 'later';
|
|
159
|
-
saveIndex(scope, superseded);
|
|
160
|
-
const replacement = upsert(scope, { title: '被取代的结论', summary: '沿同名标题重写', body: '新' });
|
|
161
|
-
assert.equal(replacement.needsResolution, false);
|
|
162
|
-
assert.notEqual(replacement.record.id, second.record.id);
|
|
163
|
-
});
|
|
164
|
-
|
|
165
|
-
test('expired reads report their lifecycle and empty reads do not count as usage', () => {
|
|
166
|
-
const scope = fresh('lifecycle-read');
|
|
167
|
-
const first = upsert(scope, { title: '过期条目', summary: '已经过了有效期', body: '旧正文' });
|
|
168
|
-
const index = loadIndex(scope);
|
|
169
|
-
index.entries.find((row) => row.id === first.record.id).validUntil = new Date(Date.now() - 1000).toISOString();
|
|
170
|
-
index.entries.find((row) => row.id === first.record.id).lastUsed = '2026-01-01T00:00:00.000Z';
|
|
171
|
-
saveIndex(scope, index);
|
|
172
|
-
const expired = readEntry(scope, first.record.id);
|
|
173
|
-
assert.equal(expired.status, 'expired');
|
|
174
|
-
assert.equal(expired.body, '旧正文');
|
|
175
|
-
assert.equal(loadIndex(scope).entries.find((row) => row.id === first.record.id).lastUsed, '2026-01-01T00:00:00.000Z');
|
|
176
|
-
|
|
177
|
-
const empty = upsert(scope, { title: '空正文条目', summary: '只有摘要', body: '' }).record;
|
|
178
|
-
loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
|
|
179
|
-
const saved = loadIndex(scope);
|
|
180
|
-
saved.entries.find((row) => row.id === empty.id).lastUsed = '2026-01-01T00:00:00.000Z';
|
|
181
|
-
saveIndex(scope, saved);
|
|
182
|
-
const record = readEntry(scope, empty.id);
|
|
183
|
-
assert.equal(record.body, '');
|
|
184
|
-
assert.equal(loadIndex(scope).entries.find((row) => row.id === empty.id).lastUsed, '2026-01-01T00:00:00.000Z', '空读不算用过');
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test('oversized body is rejected, old data untouched', () => {
|
|
188
|
-
const scope = fresh('oversize');
|
|
189
|
-
const big = 'x'.repeat(LIMITS.entryBytes + 1);
|
|
190
|
-
assert.throws(() => upsert(scope, { title: '太大', summary: '一条超大正文', body: big }), /超过/);
|
|
191
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
|
|
192
|
-
const first = upsert(scope, { title: '正常', summary: '一条正常正文', body: '保留我' });
|
|
193
|
-
assert.throws(() => upsert(scope, { id: first.record.id, title: '正常', summary: '一条正常正文', body: big }), /超过/);
|
|
194
|
-
assert.equal(bodyOf(scope, first.record.id), '保留我');
|
|
195
|
-
assert.equal(loadIndex(scope).entries[0].revision, 1);
|
|
196
|
-
});
|
|
197
|
-
|
|
198
|
-
test('over-long title is an error; over-long summary keeps its full text outside the body', () => {
|
|
199
|
-
const scope = fresh('length');
|
|
200
|
-
assert.throws(() => upsert(scope, { title: 't'.repeat(LIMITS.titleChars + 1), summary: '写得短一点' }), /标题超过/);
|
|
201
|
-
const long = '长'.repeat(LIMITS.summaryChars + 1);
|
|
202
|
-
const saved = upsert(scope, { title: '长摘要', summary: long });
|
|
203
|
-
assert.equal(saved.record.summary.length, LIMITS.summaryChars);
|
|
204
|
-
/** 完整原句进维护区,正文保持空:不再往正文里补「摘要原文」尾巴。 */
|
|
205
|
-
assert.equal(readEntry(scope, saved.record.id).body, '');
|
|
206
|
-
assert.equal(saved.record.summaryFull, long);
|
|
207
|
-
assert.equal(readEntry(scope, saved.record.id).maintenance.summaryFull, long);
|
|
208
|
-
assert.equal(saved.draft.fullTextSaved, true);
|
|
209
|
-
assert.equal(saved.draft.fullTextInBody, false);
|
|
210
|
-
assert.equal(saved.draft.summaryShortened, true);
|
|
211
|
-
});
|
|
212
|
-
|
|
213
|
-
test('imperative summary is kept; credentials are still rejected', () => {
|
|
214
|
-
const scope = fresh('validate');
|
|
215
|
-
assert.equal(upsert(scope, { title: '回复语言', summary: '请用中文回复' }).record.summary, '请用中文回复');
|
|
216
|
-
assert.throws(() => upsert(scope, { title: '凭据', summary: '一个 token 的值', body: 'token = sk-abcdefghijklmnopqrst' }), /凭据/);
|
|
217
|
-
assert.equal(normalizeDraft({ title: 'a', summary: '名词短语' }).kind, 'note');
|
|
218
|
-
assert.throws(() => normalizeDraft({ title: 'a', summary: '名词短语', kind: 'guess' }), /类型只能是/);
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test('正文预算满了也先归档腾位,并在结果里点名', () => {
|
|
222
|
-
const scope = fresh('budget');
|
|
223
|
-
const original = LIMITS.totalBytes;
|
|
224
|
-
LIMITS.totalBytes = 600;
|
|
225
|
-
try {
|
|
226
|
-
upsert(scope, { title: '第一条', summary: '占满预算的正文', body: '记'.repeat(100) });
|
|
227
|
-
const second = upsert(scope, { title: '第二条', summary: '再来一条就超了', body: '记'.repeat(100) });
|
|
228
|
-
assert.equal(second.record.title, '第二条');
|
|
229
|
-
assert.deepEqual(second.archived.map((item) => item.title), ['第一条']);
|
|
230
|
-
assert.equal(countOf(scope), 1);
|
|
231
|
-
} finally {
|
|
232
|
-
LIMITS.totalBytes = original;
|
|
233
|
-
}
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
test('lock file from a dead writer is not stolen: busy instead of a guessed write', () => {
|
|
237
|
-
const scope = fresh('lock');
|
|
238
|
-
fs.mkdirSync(scope.dir, { recursive: true });
|
|
239
|
-
const lock = path.join(scope.dir, '.lock');
|
|
240
|
-
fs.writeFileSync(lock, 'dead:token');
|
|
241
|
-
const old = new Date(Date.now() - 60_000);
|
|
242
|
-
fs.utimesSync(lock, old, old);
|
|
243
|
-
const started = Date.now();
|
|
244
|
-
assert.throws(() => upsert(scope, { title: '拿不到锁', summary: '锁被别人拿着' }), /正忙/);
|
|
245
|
-
const waited = Date.now() - started;
|
|
246
|
-
assert.ok(waited >= 400 && waited < 3000, `锁等待 ${waited}ms`);
|
|
247
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
|
|
248
|
-
fs.rmSync(lock, { force: true });
|
|
249
|
-
const ok = upsert(scope, { title: '拿得到锁', summary: '锁被清掉之后' });
|
|
250
|
-
assert.equal(ok.merged, false);
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
test('search finds Chinese queries, when clauses and reports counts', () => {
|
|
254
|
-
const scope = fresh('search');
|
|
255
|
-
upsert(scope, { title: '发布顺序', summary: '发版前要按顺序做的事', body: 'a' });
|
|
256
|
-
upsert(scope, { title: '文案检查', summary: '改完界面跑一遍 lint', when: 'CI 容器里', body: 'b' });
|
|
257
|
-
const hits = search(scope, '上次发布流程是什么');
|
|
258
|
-
assert.ok(hits.length >= 1);
|
|
259
|
-
assert.equal(hits[0].title, '发布顺序');
|
|
260
|
-
assert.equal(hits[0].scope, 'project');
|
|
261
|
-
const when = search(scope, 'CI 容器');
|
|
262
|
-
assert.ok(when.some((item) => item.title === '文案检查'));
|
|
263
|
-
const rendered = renderIndex(hits);
|
|
264
|
-
assert.match(rendered.text, /^- 「发布顺序」/);
|
|
265
|
-
assert.doesNotMatch(rendered.text, /mem_[a-z0-9]+/);
|
|
266
|
-
assert.doesNotMatch(rendered.text, /第 .* 版/);
|
|
267
|
-
assert.ok(rendered.chars <= 400);
|
|
268
|
-
assert.deepEqual(search(scope, '完全无关的主题词'), []);
|
|
269
|
-
});
|
|
270
|
-
|
|
271
|
-
test('archived or 90-day-stale entries render without a summary', () => {
|
|
272
|
-
const scope = fresh('stale');
|
|
273
|
-
const item = upsert(scope, { title: '旧条目', summary: '很久没用过的条目', body: 'x' });
|
|
274
|
-
const index = loadIndex(scope);
|
|
275
|
-
index.entries[0].lastUsed = new Date(Date.now() - 120 * 24 * 60 * 60 * 1000).toISOString();
|
|
276
|
-
store.saveIndex(scope, index);
|
|
277
|
-
const hits = search(scope, '旧条目');
|
|
278
|
-
assert.equal(hits.length, 1);
|
|
279
|
-
const rendered = renderIndex(hits);
|
|
280
|
-
assert.ok(!rendered.text.includes('|'), rendered.text);
|
|
281
|
-
assert.equal(rendered.text.split('\n')[0].includes(item.record.id), false, '索引行只给标题');
|
|
282
|
-
assert.match(rendered.text.split('\n')[0], /「旧条目」/);
|
|
283
|
-
});
|
|
284
|
-
|
|
285
|
-
test('search and read leave hits and lastUsed alone', () => {
|
|
286
|
-
const scope = fresh('hits');
|
|
287
|
-
const item = upsert(scope, { title: '常用条目', summary: '经常被翻出来的条目', body: 'x' });
|
|
288
|
-
const before = loadIndex(scope).entries.find((entry) => entry.id === item.record.id);
|
|
289
|
-
search(scope, '常用条目');
|
|
290
|
-
readEntry(scope, item.record.id);
|
|
291
|
-
const after = loadIndex(scope).entries.find((entry) => entry.id === item.record.id);
|
|
292
|
-
assert.equal(after.hits, before.hits);
|
|
293
|
-
assert.equal(after.lastUsed, before.lastUsed);
|
|
294
|
-
assert.equal(loadIndex(scope).generation, before.generation ?? loadIndex(scope).generation);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test('readEntry returns the body from state, unknown ids are null', () => {
|
|
298
|
-
const scope = fresh('read');
|
|
299
|
-
const item = upsert(scope, { title: '读正文', summary: '正文要读得出来', body: '正文内容' });
|
|
300
|
-
const record = readEntry(scope, item.record.id);
|
|
301
|
-
assert.equal(record.body, '正文内容');
|
|
302
|
-
assert.equal(readEntry(scope, 'mem_deadbeef0000'), null);
|
|
303
|
-
assert.equal(store.readCitationBody('session-read', 'mem_deadbeef0000'), null);
|
|
304
|
-
});
|
|
305
|
-
|
|
306
|
-
test('forget archives both body and record; undo restores the previous version', () => {
|
|
307
|
-
const scope = fresh('undo');
|
|
308
|
-
const first = upsert(scope, { title: '会改两次', summary: '需要撤销的一条', body: '第一版正文' });
|
|
309
|
-
upsert(scope, { id: first.record.id, title: '会改两次', summary: '需要撤销的一条', body: '第二版正文' });
|
|
310
|
-
const restored = undoChange(scope, first.record.id);
|
|
311
|
-
assert.equal(restored.mode, 'restored');
|
|
312
|
-
assert.equal(bodyOf(scope, first.record.id), '第一版正文');
|
|
313
|
-
assert.equal(loadIndex(scope).entries[0].revision, 3);
|
|
314
|
-
const again = undoChange(scope, first.record.id);
|
|
315
|
-
assert.equal(again.mode, 'archived');
|
|
316
|
-
assert.equal(countOf(scope), 0);
|
|
317
|
-
const archived = loadIndex(scope).archived.find((item) => item.id === first.record.id);
|
|
318
|
-
assert.equal(archived.status, 'archived');
|
|
319
|
-
assert.equal(archived.body, '', '列表只拿归档元数据,不读冷存正文');
|
|
320
|
-
assert.equal(readEntry(scope, first.record.id).body, '第一版正文', '真要读这一条时才从冷存取');
|
|
321
|
-
const againArchive = forget(scope, first.record.id, { mode: 'archive' });
|
|
322
|
-
assert.equal(againArchive.mode, 'archive');
|
|
323
|
-
assert.equal(againArchive.already, true);
|
|
324
|
-
});
|
|
325
|
-
|
|
326
|
-
test('suggestion gate, keep, replay and reject', () => {
|
|
327
|
-
const scope = fresh('candidate');
|
|
328
|
-
const gate = suggestionGate(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint' });
|
|
329
|
-
assert.equal(gate.ok, true);
|
|
330
|
-
const noted = noteSuggestion(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: 'node ui-copy-lint.mjs', when: '发布前' });
|
|
331
|
-
assert.match(noted.id, /^sug_[a-z0-9]+$/);
|
|
332
|
-
assert.equal(noted.targetId, '');
|
|
333
|
-
const found = findSuggestion(scope, noted.id);
|
|
334
|
-
assert.equal(found.when, '发布前');
|
|
335
|
-
assert.equal(found.suggestionId, noted.id);
|
|
336
|
-
const kept = acceptCandidate(scope, noted.id, 'op_first');
|
|
337
|
-
assert.equal(kept.ok, true);
|
|
338
|
-
assert.equal(kept.mode, 'created');
|
|
339
|
-
assert.equal(kept.title, '发布前跑文案检查');
|
|
340
|
-
assert.equal(countOf(scope), 1);
|
|
341
|
-
assert.equal(suggestionsOf(scope).length, 0);
|
|
342
|
-
const replay = acceptCandidate(scope, noted.id, 'op_first');
|
|
343
|
-
assert.equal(replay.ok, true);
|
|
344
|
-
assert.equal(replay.replay, true);
|
|
345
|
-
assert.equal(replay.id, kept.id);
|
|
346
|
-
assert.equal(acceptCandidate(scope, noted.id, 'op_other').ok, false);
|
|
347
|
-
assert.equal(findSuggestion(scope, noted.id), null);
|
|
348
|
-
assert.equal(countOf(scope), 1);
|
|
349
|
-
const third = noteSuggestion(scope, { title: '另一条建议', summary: '用来测试 reject 的候选' });
|
|
350
|
-
const dropped = rejectCandidate(scope, third.id);
|
|
351
|
-
assert.equal(dropped.ok, true);
|
|
352
|
-
assert.equal(rejectCandidate(scope, third.id).ok, false);
|
|
353
|
-
/** 拒绝过的候选在时限内不再冒出来,而不是靠 48 小时节流碰巧挡住。 */
|
|
354
|
-
const retry = suggestionGate(scope, { title: '另一条建议', summary: '用来测试 reject 的候选' });
|
|
355
|
-
assert.equal(retry.ok, false);
|
|
356
|
-
assert.match(retry.reason, /拒绝/);
|
|
357
|
-
});
|
|
358
|
-
|
|
359
|
-
test('未确认的项目根目录不新建命名空间,显式全局仍然可写', () => {
|
|
360
|
-
const project = fresh('unconfirmed');
|
|
361
|
-
const unconfirmed = { ...project, confirmed: false };
|
|
362
|
-
const blocked = store.saveEntry(unconfirmed, { title: '未确认目录里的规则', summary: '不该落进项目层', body: '正文' });
|
|
363
|
-
assert.equal(blocked.needsScope, true);
|
|
364
|
-
assert.equal(blocked.reasonCode, 'unconfirmed_project');
|
|
365
|
-
assert.equal(store.loadIndex(project).entries.length, 0);
|
|
366
|
-
const globalSaved = store.saveEntry(unconfirmed, {
|
|
367
|
-
scope: 'global',
|
|
368
|
-
memoryClass: 'personal_preference',
|
|
369
|
-
title: '跨项目也成立的偏好',
|
|
370
|
-
summary: '不依赖项目根目录',
|
|
371
|
-
body: '正文',
|
|
372
|
-
});
|
|
373
|
-
assert.equal(globalSaved.needsScope, false);
|
|
374
|
-
assert.equal(globalSaved.chosenScope, 'global');
|
|
375
|
-
assert.equal(store.loadIndex(globalScope()).entries.some((item) => item.id === globalSaved.record.id), true);
|
|
376
|
-
const created = upsert(project, { title: '已存在的项目规则', summary: '用来测更新', body: '旧正文' });
|
|
377
|
-
const updateBlocked = store.saveEntry(unconfirmed, { id: created.record.id, title: '已存在的项目规则', summary: '用来测更新', body: '新正文' });
|
|
378
|
-
assert.equal(updateBlocked.needsScope, true);
|
|
379
|
-
assert.equal(updateBlocked.reasonCode, 'unconfirmed_project');
|
|
380
|
-
});
|
|
381
|
-
|
|
382
|
-
test('空正文更新也留版本,撤销不会当成新建', () => {
|
|
383
|
-
const scope = fresh('empty-body');
|
|
384
|
-
const created = upsert(scope, { title: '空正文起步', summary: '正文之后补', body: '' });
|
|
385
|
-
const updated = upsert(scope, { id: created.record.id, title: '空正文起步', summary: '正文之后补', body: '后来补的正文' });
|
|
386
|
-
assert.equal(updated.previousBody, '');
|
|
387
|
-
const undone = undoChange(scope, created.record.id);
|
|
388
|
-
assert.equal(undone.mode, 'restored');
|
|
389
|
-
const current = readEntry(scope, created.record.id);
|
|
390
|
-
assert.equal(current.body, '');
|
|
391
|
-
assert.equal(current.revision, 3);
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
test('suggestion without a body never clears the target body', () => {
|
|
395
|
-
const scope = fresh('candidate-update');
|
|
396
|
-
const original = upsert(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: '保留这段正文' });
|
|
397
|
-
const gate = suggestionGate(scope, { title: '发布前跑文案检查(含全局)', summary: '发版前先跑 ui 文案 lint' });
|
|
398
|
-
assert.equal(gate.ok, true);
|
|
399
|
-
assert.equal(gate.target.id, original.record.id);
|
|
400
|
-
const noted = noteSuggestion(scope, { title: '发布前跑文案检查(含全局)', summary: '发版前先跑 ui 文案 lint', targetId: gate.target.id });
|
|
401
|
-
const kept = acceptCandidate(scope, noted.id, 'op_update');
|
|
402
|
-
assert.equal(kept.ok, true);
|
|
403
|
-
assert.equal(kept.mode, 'updated');
|
|
404
|
-
assert.equal(kept.id, original.record.id);
|
|
405
|
-
assert.equal(countOf(scope), 1);
|
|
406
|
-
assert.equal(bodyOf(scope, original.record.id), '保留这段正文');
|
|
407
|
-
assert.equal(loadIndex(scope).entries[0].title, '发布前跑文案检查(含全局)');
|
|
408
|
-
assert.deepEqual(loadIndex(scope).entries[0].aliases, ['发布前跑文案检查']);
|
|
409
|
-
});
|
|
410
|
-
|
|
411
|
-
test('candidate pointing at a changed entry is refused, not overwritten', () => {
|
|
412
|
-
const scope = fresh('candidate-stale');
|
|
413
|
-
const original = upsert(scope, { title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: '第一版' });
|
|
414
|
-
const noted = noteSuggestion(scope, { title: '发布前跑文案检查(含全局)', summary: '发版前先跑 ui 文案 lint', targetId: original.record.id });
|
|
415
|
-
upsert(scope, { id: original.record.id, title: '发布前跑文案检查', summary: '发版前先跑 ui 文案 lint', body: '第二版' });
|
|
416
|
-
const kept = acceptCandidate(scope, noted.id, 'op_stale');
|
|
417
|
-
assert.equal(kept.ok, false);
|
|
418
|
-
assert.match(kept.reason, /改过/);
|
|
419
|
-
assert.equal(bodyOf(scope, original.record.id), '第二版');
|
|
420
|
-
});
|
|
421
|
-
|
|
422
|
-
test('expired candidates are invisible and unusable', () => {
|
|
423
|
-
const scope = fresh('candidate-ttl');
|
|
424
|
-
const noted = noteSuggestion(scope, { title: '会过期的建议', summary: '还没确认就过期了' });
|
|
425
|
-
const index = loadIndex(scope);
|
|
426
|
-
index.suggestions[0].expiresAt = new Date(Date.now() - 1000).toISOString();
|
|
427
|
-
store.saveIndex(scope, index);
|
|
428
|
-
assert.equal(findSuggestion(scope, noted.id), null);
|
|
429
|
-
assert.equal(acceptCandidate(scope, noted.id, '').ok, false);
|
|
430
|
-
assert.equal(countOf(scope), 0);
|
|
431
|
-
});
|
|
432
|
-
|
|
433
|
-
test('candidate draft is validated like a real entry', () => {
|
|
434
|
-
const scope = fresh('candidate-bad');
|
|
435
|
-
assert.throws(() => noteSuggestion(scope, { title: '凭据候选', summary: '一个 token 的值', body: 'ghp_abcdefghijklmnopqrstuvwx' }), /凭据/);
|
|
436
|
-
assert.throws(() => noteSuggestion(scope, { title: '超大候选', summary: '正文超过单条上限', body: 'x'.repeat(LIMITS.entryBytes + 1) }), /超过/);
|
|
437
|
-
assert.equal((loadIndex(scope).suggestions || []).length, 0);
|
|
438
|
-
});
|
|
439
|
-
|
|
440
|
-
test('corrupt index stops reads and writes and stays on disk', () => {
|
|
441
|
-
const scope = fresh('corrupt');
|
|
442
|
-
fs.mkdirSync(scope.dir, { recursive: true });
|
|
443
|
-
const file = path.join(scope.dir, 'index.json');
|
|
444
|
-
const broken = '{"entries": [';
|
|
445
|
-
fs.writeFileSync(file, broken);
|
|
446
|
-
assert.throws(() => loadIndex(scope), /已损坏/);
|
|
447
|
-
assert.throws(() => upsert(scope, { title: '不该写', summary: '索引坏了就不能写' }), /已损坏/);
|
|
448
|
-
assert.equal(fs.readFileSync(file, 'utf8'), broken);
|
|
449
|
-
fs.writeFileSync(file, '{"entries": 3}');
|
|
450
|
-
assert.throws(() => loadIndex(scope), /结构不对/);
|
|
451
|
-
});
|
|
452
|
-
|
|
453
|
-
test('条数 200 是提示线,不是拒绝线', () => {
|
|
454
|
-
const scope = fresh('trim');
|
|
455
|
-
for (let i = 0; i < LIMITS.entries; i += 1) {
|
|
456
|
-
upsert(scope, { title: `条目 ${i}`, summary: `第 ${i} 条压容量`, body: `body ${i}`, resolution: 'distinct' });
|
|
457
|
-
}
|
|
458
|
-
assert.equal(countOf(scope), LIMITS.entries);
|
|
459
|
-
const extra = upsert(scope, { title: '第 201 条', summary: '不许因为条数拒绝新记忆' });
|
|
460
|
-
assert.equal(extra.record.title, '第 201 条');
|
|
461
|
-
assert.deepEqual(extra.archived, []);
|
|
462
|
-
assert.equal(countOf(scope), LIMITS.entries + 1);
|
|
463
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'archive')), false);
|
|
464
|
-
});
|
|
465
|
-
|
|
466
|
-
test('到硬上限时归档最久没读的一条,不拒绝新记忆', () => {
|
|
467
|
-
const scope = fresh('evict');
|
|
468
|
-
fs.mkdirSync(scope.dir, { recursive: true });
|
|
469
|
-
const chunk = Math.ceil(LIMITS.totalBytes / 20);
|
|
470
|
-
const entries = Array.from({ length: 20 }, (_, i) => {
|
|
471
|
-
const day = String(i + 1).padStart(2, '0');
|
|
472
|
-
return {
|
|
473
|
-
id: `mem_${String(i).padStart(12, '0')}`,
|
|
474
|
-
title: `旧条目 ${i}`,
|
|
475
|
-
summary: `第 ${i} 条`,
|
|
476
|
-
when: '',
|
|
477
|
-
kind: 'note',
|
|
478
|
-
tags: [],
|
|
479
|
-
created: `2026-01-${day}T00:00:00.000Z`,
|
|
480
|
-
updated: `2026-01-${day}T00:00:00.000Z`,
|
|
481
|
-
lastUsed: `2026-01-${day}T00:00:00.000Z`,
|
|
482
|
-
revision: 1,
|
|
483
|
-
status: 'active',
|
|
484
|
-
source: 'user_explicit',
|
|
485
|
-
hits: 0,
|
|
486
|
-
merges: 0,
|
|
487
|
-
versions: [],
|
|
488
|
-
bytes: chunk,
|
|
489
|
-
};
|
|
490
|
-
});
|
|
491
|
-
fs.writeFileSync(path.join(scope.dir, 'index.json'), JSON.stringify({
|
|
492
|
-
version: 1, scope: scope.key, entries, suggestions: [], receipts: [],
|
|
493
|
-
}));
|
|
494
|
-
fs.mkdirSync(path.join(scope.dir, 'entries'), { recursive: true });
|
|
495
|
-
for (const entry of entries) {
|
|
496
|
-
fs.writeFileSync(path.join(scope.dir, 'entries', `${entry.id}.md`), 'x'.repeat(chunk));
|
|
497
|
-
}
|
|
498
|
-
const saved = upsert(scope, { title: '新的一条', summary: '满了也要能存', body: '新正文' });
|
|
499
|
-
assert.equal(saved.record.title, '新的一条');
|
|
500
|
-
assert.equal(saved.archived.length, 1);
|
|
501
|
-
assert.equal(saved.archived[0].title, '旧条目 0');
|
|
502
|
-
assert.equal(countOf(scope), 20);
|
|
503
|
-
const archived = stateOf(scope).entries.find((row) => row.id === saved.archived[0].id);
|
|
504
|
-
assert.equal(archived.status, 'archived');
|
|
505
|
-
assert.equal(archived.current.title, '旧条目 0');
|
|
506
|
-
assert.equal(archived.current.body.length, chunk);
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
test('过期回执在任意写入时清掉,不靠接受候选', () => {
|
|
510
|
-
const scope = fresh('receipt-ttl');
|
|
511
|
-
const now = Date.now();
|
|
512
|
-
const stale = new Date(now - LIMITS.receiptTtlMs - 60 * 1000).toISOString();
|
|
513
|
-
const freshAt = new Date(now - 60 * 1000).toISOString();
|
|
514
|
-
state.transact(scope, { expectCreate: true }, (draft) => {
|
|
515
|
-
draft.receipts = [{ operationId: 'op_old', at: stale, changed: 1 }, { operationId: 'op_new', at: freshAt, changed: 2 }];
|
|
516
|
-
return { changed: true };
|
|
517
|
-
});
|
|
518
|
-
const saved = upsert(scope, { title: '顺手写一条', summary: '触发一次事务', body: '正文' });
|
|
519
|
-
assert.ok(saved.record.id);
|
|
520
|
-
const kept = stateOf(scope).receipts.map((row) => row.operationId);
|
|
521
|
-
assert.deepEqual(kept, ['op_new']);
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
test('the global layer reports both lines', () => {
|
|
525
|
-
const global = globalScope();
|
|
526
|
-
assert.equal(LIMITS.globalEntries, 200);
|
|
527
|
-
const room = store.capacityOf(global, { entries: new Array(41).fill({}) });
|
|
528
|
-
assert.equal(room.cap, 200);
|
|
529
|
-
assert.equal(room.hard, LIMITS.hardEntries);
|
|
530
|
-
assert.equal(room.used, 41);
|
|
531
|
-
assert.equal(room.remaining, 159);
|
|
532
|
-
assert.equal(room.bytes, 0);
|
|
533
|
-
assert.equal(room.bytesLeft, LIMITS.totalBytes);
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
test('projects and the global layer stay separate', () => {
|
|
537
|
-
const a = fresh('layer-a');
|
|
538
|
-
const b = fresh('layer-b');
|
|
539
|
-
upsert(a, { title: '只在 A 里', summary: '另一个项目看不到' });
|
|
540
|
-
upsert(globalScope(), { title: '全局条目', summary: '所有项目都能搜到' });
|
|
541
|
-
assert.equal(search(b, '只在 A 里').some((item) => item.title === '只在 A 里'), false);
|
|
542
|
-
const global = search(b, '全局条目');
|
|
543
|
-
assert.equal(global.length, 1);
|
|
544
|
-
assert.equal(global[0].scope, 'global');
|
|
545
|
-
const rendered = renderIndex(global);
|
|
546
|
-
assert.match(rendered.text, /^- \[global\] 「全局条目」/);
|
|
547
|
-
assert.doesNotMatch(rendered.text, /mem_[a-z0-9]{6,}/);
|
|
548
|
-
const removed = forget(b, 'mem_000000000000', { mode: 'archive' });
|
|
549
|
-
assert.equal(removed, null);
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
// ── 会话引用 ────────────────────────────────────────────────────────────────
|
|
553
|
-
test('citations keep one row per id and drop the oldest', () => {
|
|
554
|
-
const scope = fresh('cite');
|
|
555
|
-
const items = Array.from({ length: LIMITS.citations + 5 }, (_, i) => upsert(scope, {
|
|
556
|
-
title: `条目 ${i}`, summary: `引用第 ${i} 条`, body: `正文 ${i}`, resolution: 'distinct',
|
|
557
|
-
}).record);
|
|
558
|
-
recordCitations(scope, 'session-cite', items.slice(0, 3));
|
|
559
|
-
assert.equal(listCitations('session-cite').items.length, 3);
|
|
560
|
-
recordCitations(scope, 'session-cite', [items[1]]);
|
|
561
|
-
const deduped = listCitations('session-cite').items;
|
|
562
|
-
assert.equal(deduped.length, 3);
|
|
563
|
-
assert.equal(deduped[0].id, items[1].id);
|
|
564
|
-
recordCitations(scope, 'session-cite', items);
|
|
565
|
-
const kept = listCitations('session-cite').items;
|
|
566
|
-
assert.equal(kept.length, LIMITS.citations);
|
|
567
|
-
assert.equal(kept[0].id, items[0].id);
|
|
568
|
-
});
|
|
569
|
-
|
|
570
|
-
test('citations refuse a path-like session id', () => {
|
|
571
|
-
const scope = fresh('cite-bad');
|
|
572
|
-
assert.deepEqual(recordCitations(scope, '../escape', [{ id: 'mem_aaaaaaaaaaaa', title: '越界' }]), []);
|
|
573
|
-
assert.deepEqual(listCitations('../../etc/passwd').items, []);
|
|
574
|
-
});
|
|
575
|
-
|
|
576
|
-
test('citation body reads the entry and rejects a foreign path', () => {
|
|
577
|
-
const scope = fresh('cite-body');
|
|
578
|
-
const created = upsert(scope, { title: '引用正文', summary: '点开要看到正文', body: '正文第一行' }).record;
|
|
579
|
-
recordCitations(scope, 'session-body', [{ ...created, scope: 'project' }]);
|
|
580
|
-
const row = listCitations('session-body').items[0];
|
|
581
|
-
assert.equal(row.title, '引用正文');
|
|
582
|
-
assert.equal('path' in row, false);
|
|
583
|
-
assert.match(readCitationBody('session-body', created.id).body, /正文第一行/);
|
|
584
|
-
assert.equal(readCitationBody('session-body', 'mem_999999999999'), null);
|
|
585
|
-
|
|
586
|
-
const dir = path.join(store.memoryRoot(), 'sessions');
|
|
587
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
588
|
-
fs.writeFileSync(path.join(dir, 'session-escape.json'), JSON.stringify({ items: [{ id: 'mem_111111111111', path: '/etc/hosts' }] }));
|
|
589
|
-
assert.equal(readCitationBody('session-escape', 'mem_111111111111'), null);
|
|
590
|
-
});
|
|
591
|
-
|
|
592
|
-
test('citation rows follow the current title and layer', () => {
|
|
593
|
-
const scope = fresh('cite-fresh');
|
|
594
|
-
const created = upsert(scope, { title: '旧标题', summary: '会被改名' }).record;
|
|
595
|
-
recordCitations(scope, 'session-fresh', [{ ...created, scope: 'project' }]);
|
|
596
|
-
assert.equal(listCitations('session-fresh').items[0].title, '旧标题');
|
|
597
|
-
upsert(scope, { id: created.id, title: '新标题', summary: '会被改名' });
|
|
598
|
-
assert.equal(listCitations('session-fresh').items[0].title, '新标题');
|
|
599
|
-
|
|
600
|
-
const global = upsert(globalScope(), { title: '跨项目规则', summary: '换个项目也成立' }).record;
|
|
601
|
-
recordCitations(scope, 'session-fresh', [{ ...global, scope: 'global' }]);
|
|
602
|
-
assert.equal(listCitations('session-fresh').items[0].scope, 'global');
|
|
603
|
-
});
|
|
604
|
-
|
|
605
|
-
test('citation body by title picks the requested layer', () => {
|
|
606
|
-
const scope = fresh('cite-layer');
|
|
607
|
-
const local = upsert(scope, { title: '同名规则', summary: '项目层版本', body: '项目正文' }).record;
|
|
608
|
-
const wide = upsert(globalScope(), { title: '同名规则', summary: '全局层版本', body: '全局正文' }).record;
|
|
609
|
-
recordCitations(scope, 'session-layer', [{ ...local, scope: 'project' }, { ...wide, scope: 'global' }]);
|
|
610
|
-
assert.match(readCitationBodyByTitle('session-layer', '同名规则', { scope: 'project' }).body, /项目正文/);
|
|
611
|
-
assert.match(readCitationBodyByTitle('session-layer', '同名规则', { scope: 'global' }).body, /全局正文/);
|
|
612
|
-
assert.equal(readCitationBodyByTitle('session-layer', '没有这条', { scope: 'global' }), null);
|
|
613
|
-
});
|
|
614
|
-
|
|
615
|
-
test('old citation session files are pruned', () => {
|
|
616
|
-
const scope = fresh('cite-prune');
|
|
617
|
-
recordCitations(scope, 'session-old', [{ id: 'mem_000000000001' }]);
|
|
618
|
-
const file = path.join(store.memoryRoot(), 'sessions', 'session-old.json');
|
|
619
|
-
assert.equal(fs.existsSync(file), true);
|
|
620
|
-
const past = new Date(Date.now() - LIMITS.citationTtlMs - 60_000);
|
|
621
|
-
fs.utimesSync(file, past, past);
|
|
622
|
-
recordCitations(scope, 'session-new', [{ id: 'mem_000000000002' }]);
|
|
623
|
-
assert.equal(fs.existsSync(file), false);
|
|
624
|
-
assert.equal(listCitations('session-new').items.length, 0);
|
|
625
|
-
});
|
|
626
|
-
|
|
627
|
-
test('title is identity: only whitespace is normalized, nothing is cut', () => {
|
|
628
|
-
const scope = fresh('title-clean');
|
|
629
|
-
const saved = upsert(scope, {
|
|
630
|
-
title: 'Chrome 临时 profile 触发「找不到钥匙串」弹窗|macOS 上由',
|
|
631
|
-
summary: '临时 profile 起 Chrome 会弹钥匙串窗口',
|
|
632
|
-
when: 'macOS 上由 agent 启动 Chrome 且用临时 profile 时',
|
|
633
|
-
});
|
|
634
|
-
assert.equal(saved.record.title, 'Chrome 临时 profile 触发「找不到钥匙串」弹窗|macOS 上由');
|
|
635
|
-
|
|
636
|
-
const pasted = normalizeDraft({ title: '发布顺序 (发版前要按顺序做的事)', summary: '发版前要按顺序做的事', when: '发版前要按顺序做的事' });
|
|
637
|
-
assert.equal(pasted.title, '发布顺序 (发版前要按顺序做的事)');
|
|
638
|
-
|
|
639
|
-
const spaced = normalizeDraft({ title: ' 发布 顺序 ', summary: '发版前要按顺序做的事' });
|
|
640
|
-
assert.equal(spaced.title, '发布 顺序');
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
test('distinct keeps two entries whose titles normalize to the same name', () => {
|
|
644
|
-
const scope = fresh('title-distinct');
|
|
645
|
-
const first = upsert(scope, { title: '菜单|工作日', summary: '工作日菜单', body: '周一到周五' });
|
|
646
|
-
const second = upsert(scope, { title: '菜单|周末', summary: '周末菜单', body: '周六周日', resolution: 'distinct' });
|
|
647
|
-
assert.notEqual(first.record.id, second.record.id);
|
|
648
|
-
assert.equal(countOf(scope), 2);
|
|
649
|
-
assert.equal(search(scope, '菜单').length, 2);
|
|
650
|
-
|
|
651
|
-
const sameAgain = upsert(scope, { title: '菜单|工作日', summary: '工作日菜单另存一份', resolution: 'distinct' });
|
|
652
|
-
assert.notEqual(sameAgain.record.id, first.record.id);
|
|
653
|
-
assert.equal(countOf(scope), 3);
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
test('multi-word search keeps only entries the query really hits (no single-common-word noise)', () => {
|
|
657
|
-
const scope = fresh('relevance');
|
|
658
|
-
upsert(scope, { title: '插件刷新崩溃', summary: '改完插件刷新前端会崩', when: '改完插件要刷新时' });
|
|
659
|
-
upsert(scope, { title: '插件市场收录流程', summary: '提交卡片与预检', when: '上架插件市场前' });
|
|
660
|
-
upsert(scope, { title: '插件安装位置', summary: '本地开发的插件装到 profile' });
|
|
661
|
-
|
|
662
|
-
const hits = search(scope, '刷新插件 reload 前端');
|
|
663
|
-
assert.deepEqual(hits.map((item) => item.title), ['插件刷新崩溃']);
|
|
664
|
-
|
|
665
|
-
const none = search(scope, '本地视频 侧边栏 播放');
|
|
666
|
-
assert.deepEqual(none, []);
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
test('peek hints on real title overlap and never writes hits', () => {
|
|
670
|
-
const scope = fresh('peek');
|
|
671
|
-
const saved = upsert(scope, { title: 'Chrome 弹钥匙串窗口', summary: '临时 profile 起 Chrome 会弹钥匙串窗口' });
|
|
672
|
-
const before = loadIndex(scope).entries.find((item) => item.id === saved.record.id);
|
|
673
|
-
const hits = store.peek(scope, '又弹钥匙串窗口了,怎么弄', 3);
|
|
674
|
-
assert.equal(hits.length, 1);
|
|
675
|
-
assert.equal(hits[0].id, saved.record.id);
|
|
676
|
-
assert.equal(hits[0].scope, 'project');
|
|
677
|
-
const after = loadIndex(scope).entries.find((item) => item.id === saved.record.id);
|
|
678
|
-
assert.equal(after.hits, before.hits);
|
|
679
|
-
assert.equal(after.lastUsed, before.lastUsed);
|
|
680
|
-
assert.deepEqual(store.peek(scope, '把这个按钮的圆角改小一点', 3), []);
|
|
681
|
-
});
|
|
682
|
-
|
|
683
|
-
test('candidate keeps field presence: explicit empty body clears, omitted fields stay', () => {
|
|
684
|
-
const scope = fresh('candidate-presence');
|
|
685
|
-
const created = upsert(scope, {
|
|
686
|
-
title: '字段存在', summary: '省略保持原样', when: '项目内', body: '原正文', tags: ['旧标签'],
|
|
687
|
-
}).record;
|
|
688
|
-
const noted = noteSuggestion(scope, {
|
|
689
|
-
title: '字段存在 改名', summary: '换成新摘要', body: '', targetId: created.id,
|
|
690
|
-
});
|
|
691
|
-
const kept = acceptCandidate(scope, noted.id, '');
|
|
692
|
-
assert.equal(kept.ok, true);
|
|
693
|
-
const after = readEntry(scope, created.id);
|
|
694
|
-
assert.equal(after.title, '字段存在 改名');
|
|
695
|
-
assert.equal(after.summary, '换成新摘要');
|
|
696
|
-
assert.equal(after.when, '项目内');
|
|
697
|
-
assert.deepEqual(after.tags, ['旧标签']);
|
|
698
|
-
assert.equal(after.body, '');
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
test('归档条目仍能搜到并标已归档,正文也能读', () => {
|
|
702
|
-
const scope = fresh('archived-search');
|
|
703
|
-
const created = upsert(scope, { title: '旧端口约定', summary: 'unlag 走 49661', body: '49661' }).record;
|
|
704
|
-
forget(scope, created.id, { mode: 'archive' });
|
|
705
|
-
/** 归档后默认检索不到;只有明确查历史才列出来,并如实标成归档。 */
|
|
706
|
-
assert.equal(search(scope, '旧端口约定', 3, {}).length, 0);
|
|
707
|
-
const hits = search(scope, '旧端口约定', 3, { history: true });
|
|
708
|
-
assert.equal(hits.length, 1);
|
|
709
|
-
assert.equal(hits[0].archived, true);
|
|
710
|
-
assert.equal(hits[0].status, 'archived');
|
|
711
|
-
const entry = readEntry(scope, created.id);
|
|
712
|
-
assert.equal(entry.status, 'archived');
|
|
713
|
-
assert.equal(entry.body, '49661');
|
|
714
|
-
});
|
|
715
|
-
|
|
716
|
-
test('归档条目也能彻底删除,旧版本与残留正文一并清掉', () => {
|
|
717
|
-
const scope = fresh('purge-archived');
|
|
718
|
-
const saved = upsert(scope, { title: '归档后再忘掉', summary: '管理动作覆盖归档条目', body: '第一版' });
|
|
719
|
-
upsert(scope, { id: saved.record.id, title: '归档后再忘掉', summary: '管理动作覆盖归档条目', body: '第二版' });
|
|
720
|
-
forget(scope, saved.record.id, { mode: 'archive' });
|
|
721
|
-
assert.equal(loadIndex(scope).archived.length, 1);
|
|
722
|
-
const purged = forget(scope, saved.record.id, { mode: 'purge' });
|
|
723
|
-
assert.equal(purged.mode, 'purged');
|
|
724
|
-
assert.equal(loadIndex(scope).archived.length, 0);
|
|
725
|
-
assert.equal(stateOf(scope).entries.length, 0);
|
|
726
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'entries', `${saved.record.id}.md`)), false);
|
|
727
|
-
assert.equal(forget(scope, saved.record.id, { mode: 'purge' }), null);
|
|
728
|
-
});
|
|
729
|
-
|
|
730
|
-
test('上次彻底删除没做完时,forget 先按原层收尾再返回原回执', () => {
|
|
731
|
-
const scope = fresh('purge-pending');
|
|
732
|
-
const saved = upsert(scope, { title: '删到一半', summary: '屏障期间的重试', body: 'x' });
|
|
733
|
-
const payload = stateOf(scope);
|
|
734
|
-
const op = 'purge_test_pending';
|
|
735
|
-
fs.writeFileSync(path.join(scope.dir, 'purge.intent.json'), `${JSON.stringify({
|
|
736
|
-
operationId: op, scopeKey: scope.key, storeId: payload.storeId, fromEpoch: payload.epoch,
|
|
737
|
-
toEpoch: 'epoch-after-purge', ids: [saved.record.id], phase: 'planned', at: new Date().toISOString(),
|
|
738
|
-
})}
|
|
739
|
-
`);
|
|
740
|
-
const purged = forget(scope, saved.record.id, { mode: 'purge' });
|
|
741
|
-
assert.equal(purged.mode, 'purged');
|
|
742
|
-
assert.equal(purged.operationId, op);
|
|
743
|
-
assert.equal(stateOf(scope).entries.length, 0);
|
|
744
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
|
|
745
|
-
});
|
|
746
|
-
|
|
747
|
-
test('归档条目的彻底删除同样先过屏障,断点收尾后不留正文', () => {
|
|
748
|
-
const scope = fresh('purge-archived-pending');
|
|
749
|
-
const saved = upsert(scope, { title: '归档删一半', summary: '归档条目也走屏障', body: '旧正文' });
|
|
750
|
-
upsert(scope, { id: saved.record.id, title: '归档删一半', summary: '归档条目也走屏障', body: '新正文' });
|
|
751
|
-
forget(scope, saved.record.id, { mode: 'archive' });
|
|
752
|
-
const payload = stateOf(scope);
|
|
753
|
-
fs.writeFileSync(path.join(scope.dir, 'purge.intent.json'), `${JSON.stringify({
|
|
754
|
-
operationId: 'purge_archived_test', scopeKey: scope.key, storeId: payload.storeId, fromEpoch: payload.epoch,
|
|
755
|
-
toEpoch: 'epoch-after-archived-purge', ids: [saved.record.id], phase: 'planned', at: new Date().toISOString(),
|
|
756
|
-
})}
|
|
757
|
-
`);
|
|
758
|
-
assert.throws(() => search(scope, '归档删一半', 3, { history: true }), (error) => error.code === 'purge_pending');
|
|
759
|
-
const purged = forget(scope, saved.record.id, { mode: 'purge' });
|
|
760
|
-
assert.equal(purged.mode, 'purged');
|
|
761
|
-
assert.equal(purged.operationId, 'purge_archived_test');
|
|
762
|
-
assert.equal(loadIndex(scope).archived.length, 0);
|
|
763
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'purge.intent.json')), false);
|
|
764
|
-
assert.equal(search(scope, '归档删一半', 3, {}).length, 0);
|
|
765
|
-
});
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
test('整层写回不丢来源、检查和学习字段,也不改写未知来源值', () => {
|
|
769
|
-
const scope = fresh('codec-full');
|
|
770
|
-
upsert(scope, { title: '代码往返', summary: '整层写回要保住扩展字段', body: '正文' });
|
|
771
|
-
const saved = stateOf(scope).entries[0];
|
|
772
|
-
state.transact(scope, {}, (draft) => {
|
|
773
|
-
const row = draft.entries.find((entry) => entry.id === saved.id);
|
|
774
|
-
row.origin = 'project_evolution';
|
|
775
|
-
row.maintenance.sourceRefs = [{ kind: 'message', id: 'msg_1', sessionId: 's1' }];
|
|
776
|
-
row.maintenance.checks = [{ at: '2026-01-01T00:00:00.000Z', result: 'passed' }];
|
|
777
|
-
row.maintenance.learning = { state: 'trial', episodeId: 'ep_1', support: 2 };
|
|
778
|
-
return { changed: true };
|
|
779
|
-
});
|
|
780
|
-
const loaded = loadIndex(scope);
|
|
781
|
-
const flat = loaded.entries[0];
|
|
782
|
-
assert.equal(flat.origin, 'project_evolution');
|
|
783
|
-
assert.deepEqual(flat.sourceRefs, [{ kind: 'message', id: 'msg_1', sessionId: 's1' }]);
|
|
784
|
-
assert.deepEqual(flat.checks, [{ at: '2026-01-01T00:00:00.000Z', result: 'passed' }]);
|
|
785
|
-
assert.deepEqual(flat.learning, { state: 'trial', episodeId: 'ep_1', support: 2 });
|
|
786
|
-
upsert(scope, { id: saved.id, summary: '整层写回要保住扩展字段(改过)' });
|
|
787
|
-
const after = stateOf(scope).entries.find((entry) => entry.id === saved.id);
|
|
788
|
-
assert.equal(after.origin, 'project_evolution');
|
|
789
|
-
assert.deepEqual(after.maintenance.sourceRefs, [{ kind: 'message', id: 'msg_1', sessionId: 's1' }]);
|
|
790
|
-
assert.deepEqual(after.maintenance.checks, [{ at: '2026-01-01T00:00:00.000Z', result: 'passed' }]);
|
|
791
|
-
assert.deepEqual(after.maintenance.learning, { state: 'trial', episodeId: 'ep_1', support: 2 });
|
|
792
|
-
});
|
|
793
|
-
|
|
794
|
-
test('只改摘要或正文不会把待核对的范围放宽成普遍适用', () => {
|
|
795
|
-
const scope = fresh('scope-preserve');
|
|
796
|
-
const saved = upsert(scope, {
|
|
797
|
-
title: '发布前检查', summary: '发布前的检查项', when: '发版前', applicability: 'unresolved', body: '旧正文',
|
|
798
|
-
});
|
|
799
|
-
assert.equal(saved.record.applicability, 'unresolved');
|
|
800
|
-
const patched = upsert(scope, { id: saved.record.id, body: '新正文' });
|
|
801
|
-
assert.equal(patched.record.applicability, 'unresolved');
|
|
802
|
-
const summary = upsert(scope, { id: saved.record.id, summary: '发布前的检查项(更新)' });
|
|
803
|
-
assert.equal(summary.record.applicability, 'unresolved');
|
|
804
|
-
const cleared = upsert(scope, { id: saved.record.id, when: '' });
|
|
805
|
-
assert.equal(cleared.record.applicability, 'all_in_scope');
|
|
806
|
-
});
|
|
807
|
-
|
|
808
|
-
test('旧候选 has=true 但条件缺失时拒绝接受并作废候选', () => {
|
|
809
|
-
const scope = fresh('candidate-broken');
|
|
810
|
-
const asked = noteSuggestion(scope, {
|
|
811
|
-
title: '后期配色', summary: '后期统一用暖色', conditions: [{ key: 'taskKind', value: 'video' }],
|
|
812
|
-
});
|
|
813
|
-
state.transact(scope, {}, (draft) => {
|
|
814
|
-
const row = draft.candidates.find((item) => item.id === asked.id);
|
|
815
|
-
delete row.draft.conditions;
|
|
816
|
-
return { changed: true };
|
|
817
|
-
});
|
|
818
|
-
const result = acceptCandidate(scope, asked.id);
|
|
819
|
-
assert.equal(result.ok, false);
|
|
820
|
-
assert.equal(result.invalid, true);
|
|
821
|
-
assert.match(result.reason, /重新建议/);
|
|
822
|
-
assert.equal(suggestionsOf(scope).length, 0);
|
|
823
|
-
assert.equal(countOf(scope), 0);
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
test('实验来源条目默认不进入检索、正文与关联,只有管理面放行', () => {
|
|
827
|
-
const scope = fresh('experimental-gate');
|
|
828
|
-
const normal = upsert(scope, { title: '普通记录', summary: '普通记录可以读', body: '普通正文' });
|
|
829
|
-
upsert(scope, { title: '实验记录', summary: '实验记录默认不可读', body: '实验正文' });
|
|
830
|
-
const target = stateOf(scope).entries.find((entry) => entry.current.title === '实验记录');
|
|
831
|
-
assert.ok(target);
|
|
832
|
-
state.transact(scope, {}, (draft) => {
|
|
833
|
-
const row = draft.entries.find((entry) => entry.id === target.id);
|
|
834
|
-
row.origin = 'project_evolution';
|
|
835
|
-
row.maintenance.learning = { state: 'paused' };
|
|
836
|
-
return { changed: true };
|
|
837
|
-
});
|
|
838
|
-
assert.equal(search(scope, '实验记录', 4).some((row) => row.id === target.id), false);
|
|
839
|
-
assert.equal(readEntry(scope, target.id), null);
|
|
840
|
-
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true }).some((row) => row.id === target.id), false);
|
|
841
|
-
assert.equal(search(scope, '实验记录', 4, { allowExperimental: true, history: true }).some((row) => row.id === target.id), true);
|
|
842
|
-
assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
|
|
843
|
-
assert.ok(readEntry(scope, normal.record.id));
|
|
844
|
-
});
|
|
845
|
-
|
|
846
|
-
test('项目实验默认关闭;打开才放行实验读取,关闭暂停试用且不自动复活', () => {
|
|
847
|
-
const scope = fresh('evolution-switch');
|
|
848
|
-
upsert(scope, { title: '实验规则', summary: '实验规则默认不参与读取', body: '实验正文' });
|
|
849
|
-
const target = stateOf(scope).entries.find((entry) => entry.current.title === '实验规则');
|
|
850
|
-
state.transact(scope, {}, (draft) => {
|
|
851
|
-
const row = draft.entries.find((entry) => entry.id === target.id);
|
|
852
|
-
row.origin = 'project_evolution';
|
|
853
|
-
row.maintenance.learning = { state: 'trial', specDigest: 'digest-1', supportedEpisodeIds: [] };
|
|
854
|
-
return { changed: true };
|
|
855
|
-
});
|
|
856
|
-
const initial = evolutionStatus(scope);
|
|
857
|
-
assert.equal(initial.enabled, false);
|
|
858
|
-
assert.equal(initial.compatible, true);
|
|
859
|
-
assert.equal(initial.counts.trial, 1);
|
|
860
|
-
assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
|
|
861
|
-
|
|
862
|
-
const on = setEvolution(scope, true, { operationId: 'op-enable-1' });
|
|
863
|
-
assert.equal(on.ok, true);
|
|
864
|
-
assert.equal(on.changed, true);
|
|
865
|
-
assert.equal(on.enabled, true);
|
|
866
|
-
assert.notEqual(on.epoch, initial.epoch);
|
|
867
|
-
assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
|
|
868
|
-
assert.equal(search(scope, '实验规则', 4, { allowExperimental: true }).some((row) => row.id === target.id), true);
|
|
869
|
-
assert.equal(readEntry(scope, target.id, { allowExperimental: true }).body, '实验正文');
|
|
870
|
-
|
|
871
|
-
const off = setEvolution(scope, false, { operationId: 'op-disable-1' });
|
|
872
|
-
assert.equal(off.ok, true);
|
|
873
|
-
assert.equal(off.enabled, false);
|
|
874
|
-
assert.equal(off.counts.paused, 1);
|
|
875
|
-
const paused = stateOf(scope).entries.find((entry) => entry.id === target.id);
|
|
876
|
-
assert.equal(paused.maintenance.learning.state, 'paused');
|
|
877
|
-
assert.equal(paused.maintenance.learning.blockedReason, 'needs_revalidation');
|
|
878
|
-
assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
|
|
879
|
-
|
|
880
|
-
const again = setEvolution(scope, true);
|
|
881
|
-
assert.equal(again.enabled, true);
|
|
882
|
-
assert.equal(again.counts.paused, 1);
|
|
883
|
-
assert.equal(again.counts.trial, 0);
|
|
884
|
-
assert.equal(search(scope, '实验规则', 4).some((row) => row.id === target.id), false);
|
|
885
|
-
});
|
|
886
|
-
|
|
887
|
-
test('旧实验规则缺作用域指纹时,打开开关只转待复核并保留正文来源', () => {
|
|
888
|
-
const scope = fresh('evolution-legacy-quarantine');
|
|
889
|
-
upsert(scope, { title: '旧实验规则', summary: '旧规则的作用域没冻结过', body: '以前留下的正文' });
|
|
890
|
-
const target = stateOf(scope).entries.find((entry) => entry.current.title === '旧实验规则');
|
|
891
|
-
assert.ok(target);
|
|
892
|
-
state.transact(scope, {}, (draft) => {
|
|
893
|
-
const row = draft.entries.find((entry) => entry.id === target.id);
|
|
894
|
-
row.origin = 'project_evolution';
|
|
895
|
-
row.maintenance.learning = { state: 'trial', trialUntil: '2099-01-01T00:00:00.000Z' };
|
|
896
|
-
row.maintenance.sourceRefs = [{ kind: 'message', id: 'msg_legacy', sessionId: 's-legacy' }];
|
|
897
|
-
return { changed: true };
|
|
898
|
-
});
|
|
899
|
-
const on = setEvolution(scope, true, { operationId: 'op-legacy-on' });
|
|
900
|
-
assert.equal(on.ok, true);
|
|
901
|
-
assert.equal(on.changed, true);
|
|
902
|
-
const row = stateOf(scope).entries.find((entry) => entry.id === target.id);
|
|
903
|
-
assert.equal(row.current.body, '以前留下的正文');
|
|
904
|
-
assert.deepEqual(row.maintenance.sourceRefs, [{ kind: 'message', id: 'msg_legacy', sessionId: 's-legacy' }]);
|
|
905
|
-
assert.equal(row.maintenance.reviewState, 'legacy_unreviewed');
|
|
906
|
-
assert.equal(row.maintenance.learning.state, 'paused');
|
|
907
|
-
assert.equal(row.maintenance.learning.blockedReason, 'needs_scope_review');
|
|
908
|
-
assert.equal(search(scope, '旧实验规则', 4).some((entry) => entry.id === target.id), false);
|
|
909
|
-
assert.equal(search(scope, '旧实验规则', 4, { allowExperimental: true }).some((entry) => entry.id === target.id), false);
|
|
910
|
-
const snap = store.learningSnapshot(scope);
|
|
911
|
-
const shown = snap.rules.find((rule) => rule.title === '旧实验规则');
|
|
912
|
-
assert.ok(shown, '待复核规则仍要出现在面板里');
|
|
913
|
-
assert.equal(shown.reviewState, 'legacy_unreviewed');
|
|
914
|
-
assert.equal(shown.state, 'paused');
|
|
915
|
-
const again = setEvolution(scope, true);
|
|
916
|
-
assert.equal(again.changed, false, '重复打开不再产生写入');
|
|
917
|
-
});
|
|
918
|
-
|
|
919
|
-
test('实验策略版本不认时保持关闭,开关拒绝改动且盘上不变', () => {
|
|
920
|
-
const scope = fresh('evolution-policy');
|
|
921
|
-
upsert(scope, { title: '普通规则', summary: '普通规则', body: '正文' });
|
|
922
|
-
state.transact(scope, {}, (draft) => {
|
|
923
|
-
draft.experimental.projectEvolution.policyVersion = 99;
|
|
924
|
-
return { changed: true };
|
|
925
|
-
});
|
|
926
|
-
const before = fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8');
|
|
927
|
-
const status = evolutionStatus(scope);
|
|
928
|
-
assert.equal(status.compatible, false);
|
|
929
|
-
assert.equal(status.enabled, false);
|
|
930
|
-
const refused = setEvolution(scope, true);
|
|
931
|
-
assert.equal(refused.ok, false);
|
|
932
|
-
assert.equal(refused.compatible, false);
|
|
933
|
-
assert.equal(fs.readFileSync(path.join(scope.dir, 'state.json'), 'utf8'), before);
|
|
934
|
-
});
|
|
935
|
-
|
|
936
|
-
test('当前不适用的高相关条目不挤掉后面真能用的条目', () => {
|
|
937
|
-
const scope = fresh('usable-rank');
|
|
938
|
-
for (let index = 0; index < 40; index += 1) {
|
|
939
|
-
upsert(scope, {
|
|
940
|
-
title: `旧规则${index}`,
|
|
941
|
-
summary: `旧规则${index}`,
|
|
942
|
-
body: `口播检查项${index}的正文`,
|
|
943
|
-
applicability: 'unresolved',
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
const usable = upsert(scope, {
|
|
947
|
-
title: '口播检查项可用',
|
|
948
|
-
summary: '口播检查项可用',
|
|
949
|
-
body: '口播检查项的可用正文',
|
|
950
|
-
applicability: 'all_in_scope',
|
|
951
|
-
});
|
|
952
|
-
const usableId = usable.record?.id ?? usable.id;
|
|
953
|
-
const hits = search(scope, '口播检查项', 3);
|
|
954
|
-
assert.equal(hits.some((row) => row.id === usableId), true);
|
|
955
|
-
});
|
|
956
|
-
|
|
957
|
-
for (const dir of dirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
958
|
-
|
|
959
|
-
test('R24 省略正文的更新不覆盖已有正文(按 id 与同名都成立)', () => {
|
|
960
|
-
const scope = fresh('omit-body');
|
|
961
|
-
const first = upsert(scope, { title: '连接池改法', summary: '旧摘要', body: '旧正文:先看连接数。' });
|
|
962
|
-
const id = first.record.id;
|
|
963
|
-
upsert(scope, { id, title: '连接池改法', summary: '新摘要' });
|
|
964
|
-
assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', 'id 更新省略正文时保留原正文');
|
|
965
|
-
upsert(scope, { title: '连接池改法', summary: '再新一点' });
|
|
966
|
-
assert.equal(bodyOf(scope, id), '旧正文:先看连接数。', '同名更新省略正文时保留原正文');
|
|
967
|
-
});
|
|
968
|
-
|
|
969
|
-
test('R25 长摘要改动即使只差第 61 个字符也算改过,完整原句另存', () => {
|
|
970
|
-
const scope = fresh('long-summary-change');
|
|
971
|
-
const head = '摘要主体'.repeat(15);
|
|
972
|
-
const first = upsert(scope, { title: '长摘要规则', summary: `${head}甲` });
|
|
973
|
-
const id = first.record.id;
|
|
974
|
-
assert.equal(first.record.summary.length, LIMITS.summaryChars);
|
|
975
|
-
assert.equal(bodyOf(scope, id), '', '正文不塞摘要原文');
|
|
976
|
-
assert.equal(first.record.summaryFull, `${head}甲`);
|
|
977
|
-
const again = upsert(scope, { id, title: '长摘要规则', summary: `${head}乙` });
|
|
978
|
-
assert.equal(again.noop, false, '第 61 个字符变化不能当作没改');
|
|
979
|
-
assert.equal(bodyOf(scope, id), '');
|
|
980
|
-
assert.equal(again.record.summaryFull, `${head}乙`, '新的完整摘要也落盘');
|
|
981
|
-
assert.equal(again.record.maintenance.summaryFull, `${head}乙`);
|
|
982
|
-
assert.equal(again.record.summary.length, LIMITS.summaryChars);
|
|
983
|
-
});
|
|
984
|
-
|
|
985
|
-
test('R26 显式长正文加短摘要时完整原句另存,不混进正文', () => {
|
|
986
|
-
const scope = fresh('body-plus-summary');
|
|
987
|
-
const long = '现场记录'.repeat(20);
|
|
988
|
-
const saved = upsert(scope, { title: '带正文的长摘要', summary: long, body: '显式正文:先复现再改。' });
|
|
989
|
-
const record = readEntry(scope, saved.record.id);
|
|
990
|
-
assert.equal(record.body, '显式正文:先复现再改。');
|
|
991
|
-
assert.doesNotMatch(record.body, /摘要原文:/);
|
|
992
|
-
assert.equal(record.summaryFull, long);
|
|
993
|
-
assert.equal(record.maintenance.summaryFull, long);
|
|
994
|
-
assert.equal(saved.draft.fullTextSaved, true);
|
|
995
|
-
assert.equal(saved.draft.fullTextInBody, false);
|
|
996
|
-
});
|
|
997
|
-
|
|
998
|
-
// ── 长摘要、用户正文里的「摘要原文:」、凭据、冷存删除 ─────────────────
|
|
999
|
-
test('用户正文里自带「摘要原文:」时原样保留,保存与再次更新都不改写', () => {
|
|
1000
|
-
const scope = fresh('user-tail-text');
|
|
1001
|
-
const body = '用户原话:\n摘要原文:这里本来就是正文的一部分,别动它。';
|
|
1002
|
-
const saved = upsert(scope, { title: '用户写的正文', summary: '用户正文原样保留', body });
|
|
1003
|
-
const id = saved.record.id;
|
|
1004
|
-
assert.equal(bodyOf(scope, id), body);
|
|
1005
|
-
upsert(scope, { id, title: '用户写的正文', summary: '再更新一次也不动正文', body });
|
|
1006
|
-
assert.equal(bodyOf(scope, id), body, '第二次更新也不能剥离用户正文');
|
|
1007
|
-
assert.equal(readEntry(scope, id).maintenance.legacySummaryTail, true, '旧标记只做待整理提示');
|
|
1008
|
-
});
|
|
1009
|
-
|
|
1010
|
-
test('长摘要改短后清掉旧的完整原句,不留托管尾巴', () => {
|
|
1011
|
-
const scope = fresh('summary-cleared');
|
|
1012
|
-
const long = '完整摘要'.repeat(20);
|
|
1013
|
-
const saved = upsert(scope, { title: '先长后短', summary: long });
|
|
1014
|
-
const id = saved.record.id;
|
|
1015
|
-
assert.equal(readEntry(scope, id).summaryFull, long);
|
|
1016
|
-
upsert(scope, { id, title: '先长后短', summary: '改成一句短的' });
|
|
1017
|
-
const record = readEntry(scope, id);
|
|
1018
|
-
assert.equal(record.summaryFull, '');
|
|
1019
|
-
assert.equal(record.maintenance.summaryFull, undefined);
|
|
1020
|
-
assert.equal(record.body, '');
|
|
1021
|
-
assert.equal(stateOf(scope).entries[0].maintenance.summaryFull, undefined, '热状态里也不留旧原文');
|
|
1022
|
-
});
|
|
1023
|
-
|
|
1024
|
-
test('冷存共享原文片段时只删目标条目,另一条的正文与历史完整', () => {
|
|
1025
|
-
const scope = fresh('cold-shared');
|
|
1026
|
-
const shared = '这段原文两条都用到';
|
|
1027
|
-
const a = upsert(scope, { title: '共享片段 A', summary: '第一条', body: `${shared},A 的细节` });
|
|
1028
|
-
const b = upsert(scope, { title: '共享片段 B', summary: '第二条', body: `${shared},B 的细节`, resolution: 'distinct' });
|
|
1029
|
-
upsert(scope, { id: b.record.id, title: '共享片段 B', summary: '第二条改过', body: `${shared},B 的第二版` });
|
|
1030
|
-
forget(scope, a.record.id, { mode: 'archive' });
|
|
1031
|
-
forget(scope, b.record.id, { mode: 'archive' });
|
|
1032
|
-
const coldA = path.join(scope.dir, 'cold', `${a.record.id}.json`);
|
|
1033
|
-
const coldB = path.join(scope.dir, 'cold', `${b.record.id}.json`);
|
|
1034
|
-
assert.equal(fs.existsSync(coldA), true);
|
|
1035
|
-
assert.equal(fs.existsSync(coldB), true);
|
|
1036
|
-
forget(scope, a.record.id, { mode: 'purge' });
|
|
1037
|
-
assert.equal(fs.existsSync(coldA), false, 'A 的冷存按 id 删除');
|
|
1038
|
-
assert.equal(fs.existsSync(coldB), true, 'B 的冷存一个字都不能动');
|
|
1039
|
-
const left = readEntry(scope, b.record.id);
|
|
1040
|
-
assert.equal(left.body, `${shared},B 的第二版`);
|
|
1041
|
-
assert.ok(left.versions.length >= 1, 'B 的历史版本还在');
|
|
1042
|
-
assert.match(JSON.stringify(fs.readFileSync(coldB, 'utf8')), /B 的第一版|B 的细节|B 的第二版/);
|
|
1043
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'cold')), true);
|
|
1044
|
-
});
|
|
1045
|
-
|
|
1046
|
-
test('冷存缺失、损坏或身份不符时报明确错误,不拿空正文顶上', () => {
|
|
1047
|
-
const scope = fresh('cold-broken');
|
|
1048
|
-
const saved = upsert(scope, { title: '冷存出错', summary: '读回要报错', body: '冷存正文:读回要报错' });
|
|
1049
|
-
const id = saved.record.id;
|
|
1050
|
-
forget(scope, id, { mode: 'archive' });
|
|
1051
|
-
const file = path.join(scope.dir, 'cold', `${id}.json`);
|
|
1052
|
-
const good = fs.readFileSync(file, 'utf8');
|
|
1053
|
-
fs.writeFileSync(file, '{ 不是 json');
|
|
1054
|
-
assert.doesNotThrow(() => loadIndex(scope), '列表不读归档正文,冷存坏了也不能整层失败');
|
|
1055
|
-
assert.throws(() => readEntry(scope, id), /冷存|cold_corrupt/);
|
|
1056
|
-
fs.writeFileSync(file, JSON.stringify({ id: 'other', scope: scope.key, revision: 1, body: '别人的正文' }));
|
|
1057
|
-
assert.throws(() => readEntry(scope, id), /冷存|cold_corrupt/);
|
|
1058
|
-
fs.rmSync(file);
|
|
1059
|
-
assert.throws(() => readEntry(scope, id), /冷存|cold_missing/);
|
|
1060
|
-
fs.writeFileSync(file, good);
|
|
1061
|
-
assert.equal(readEntry(scope, id).body, '冷存正文:读回要报错');
|
|
1062
|
-
});
|
|
1063
|
-
|
|
1064
|
-
test('归档正文懒加载:列表与 snapshot 不读冷存,只有读到该条正文才报 cold_missing', () => {
|
|
1065
|
-
const scope = fresh('cold-lazy');
|
|
1066
|
-
const saved = upsert(scope, { title: '归档懒读', summary: '列表不读正文', body: '懒加载正文:只有读这一条才取' });
|
|
1067
|
-
const id = saved.record.id;
|
|
1068
|
-
forget(scope, id, { mode: 'archive' });
|
|
1069
|
-
const file = path.join(scope.dir, 'cold', `${id}.json`);
|
|
1070
|
-
assert.equal(fs.existsSync(file), true);
|
|
1071
|
-
fs.rmSync(file);
|
|
1072
|
-
const index = loadIndex(scope);
|
|
1073
|
-
const archived = index.archived.find((row) => row.id === id);
|
|
1074
|
-
assert.ok(archived, '归档元数据照常加载');
|
|
1075
|
-
assert.equal(archived.body, '');
|
|
1076
|
-
assert.equal(archived.coldBody, '');
|
|
1077
|
-
assert.doesNotThrow(() => store.learningSnapshot(scope));
|
|
1078
|
-
assert.equal(search(scope, '归档懒读').length, 0, '列表/搜索只认真在用(active)的记录');
|
|
1079
|
-
assert.throws(() => readEntry(scope, id), /冷存|cold_missing/, '真要读这一条正文时缺冷存就报明确错误');
|
|
1080
|
-
});
|
|
1081
|
-
|
|
1082
|
-
test('被截掉的摘尾巴里藏凭据也要拒绝', () => {
|
|
1083
|
-
const scope = fresh('summary-tail-secret');
|
|
1084
|
-
const mask = '这条摘要的前半段可以短一点'.repeat(5);
|
|
1085
|
-
const secret = ' token = sk-abcdefghijklmnopqrst';
|
|
1086
|
-
assert.throws(() => upsert(scope, {
|
|
1087
|
-
title: '凭据藏尾巴', summary: `${mask}${secret}`, body: '显式正文',
|
|
1088
|
-
}), /凭据/);
|
|
1089
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'state.json')), false);
|
|
1090
|
-
});
|
|
1091
|
-
|
|
1092
|
-
test('归属路由:本机痕迹的「通用流程」留在项目层,干净流程带依据才进全局', () => {
|
|
1093
|
-
const project = { confirmed: true };
|
|
1094
|
-
const bound = store.routeScope({
|
|
1095
|
-
title: '部署脚本', summary: '本机部署脚本的位置', body: 'bash /tmp/deploy.sh,再看 127.0.0.1:8080',
|
|
1096
|
-
memoryClass: 'portable_procedure', scope: 'global',
|
|
1097
|
-
}, { project });
|
|
1098
|
-
assert.equal(bound.chosenScope, 'project');
|
|
1099
|
-
assert.equal(bound.reasonCode, 'machine_bound_scope');
|
|
1100
|
-
|
|
1101
|
-
const boundNoScope = store.routeScope({
|
|
1102
|
-
title: '本机路径', summary: '部署脚本在 /tmp 下', body: 'bash /tmp/deploy.sh',
|
|
1103
|
-
memoryClass: 'portable_procedure',
|
|
1104
|
-
}, { project });
|
|
1105
|
-
assert.equal(boundNoScope.chosenScope, 'project');
|
|
1106
|
-
assert.equal(boundNoScope.reasonCode, 'machine_bound_procedure');
|
|
1107
|
-
|
|
1108
|
-
const noEvidence = store.routeScope({
|
|
1109
|
-
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1110
|
-
memoryClass: 'portable_procedure',
|
|
1111
|
-
}, { project });
|
|
1112
|
-
assert.equal(noEvidence.chosenScope, 'project');
|
|
1113
|
-
assert.equal(noEvidence.reasonCode, 'unbound_evidence');
|
|
1114
|
-
|
|
1115
|
-
const withEvidence = store.routeScope({
|
|
1116
|
-
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1117
|
-
memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
|
|
1118
|
-
}, { project });
|
|
1119
|
-
assert.equal(withEvidence.chosenScope, 'project', '模型自动归类即便有依据也要先留在项目层');
|
|
1120
|
-
assert.equal(withEvidence.reasonCode, 'model_class_project');
|
|
1121
|
-
|
|
1122
|
-
const withEvidenceConfirmed = store.routeScope({
|
|
1123
|
-
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1124
|
-
memoryClass: 'portable_procedure', evidenceRef: '团队 wiki 的提交流程页',
|
|
1125
|
-
}, { project, humanScope: true });
|
|
1126
|
-
assert.equal(withEvidenceConfirmed.chosenScope, 'global', '人确认后才按归类落全局');
|
|
1127
|
-
assert.equal(withEvidenceConfirmed.reasonCode, 'class_portable_procedure');
|
|
1128
|
-
|
|
1129
|
-
const evidencePath = store.routeScope({
|
|
1130
|
-
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1131
|
-
memoryClass: 'portable_procedure', evidenceRef: '/tmp/notes/deploy.md',
|
|
1132
|
-
}, { project });
|
|
1133
|
-
assert.equal(evidencePath.chosenScope, 'project', '依据本身是本机文件不算普适证据');
|
|
1134
|
-
assert.equal(evidencePath.reasonCode, 'machine_bound_procedure');
|
|
1135
|
-
|
|
1136
|
-
const osOnlyProcedure = store.routeScope({
|
|
1137
|
-
title: '本机部署脚本', summary: '本机部署脚本的位置', body: 'bash /tmp/deploy.sh',
|
|
1138
|
-
memoryClass: 'portable_procedure', scope: 'global',
|
|
1139
|
-
conditions: [{ key: 'targetOs', value: 'macos' }],
|
|
1140
|
-
}, { project });
|
|
1141
|
-
assert.equal(osOnlyProcedure.chosenScope, 'project', '只写操作系统名收不住本机路径');
|
|
1142
|
-
assert.equal(osOnlyProcedure.reasonCode, 'machine_bound_scope');
|
|
1143
|
-
|
|
1144
|
-
const osOnlyClass = store.routeScope({
|
|
1145
|
-
title: '本机部署脚本', summary: '本机部署脚本的位置', body: 'bash /tmp/deploy.sh',
|
|
1146
|
-
memoryClass: 'portable_procedure',
|
|
1147
|
-
conditions: [{ key: 'targetOs', value: 'macos' }],
|
|
1148
|
-
}, { project });
|
|
1149
|
-
assert.equal(osOnlyClass.chosenScope, 'project', 'targetOs 不是机器条件,不能凭它搬去全局层');
|
|
1150
|
-
assert.equal(osOnlyClass.reasonCode, 'machine_bound_procedure');
|
|
1151
|
-
|
|
1152
|
-
const scopedProcedure = store.routeScope({
|
|
1153
|
-
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
|
1154
|
-
body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
|
|
1155
|
-
memoryClass: 'portable_procedure',
|
|
1156
|
-
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1157
|
-
}, { project });
|
|
1158
|
-
assert.equal(scopedProcedure.chosenScope, 'project', '模型自动归类不能自己搬去全局层');
|
|
1159
|
-
assert.equal(scopedProcedure.reasonCode, 'model_class_project');
|
|
1160
|
-
|
|
1161
|
-
const scopedProcedureConfirmed = store.routeScope({
|
|
1162
|
-
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
|
1163
|
-
body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
|
|
1164
|
-
memoryClass: 'portable_procedure',
|
|
1165
|
-
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1166
|
-
}, { project, humanScope: true });
|
|
1167
|
-
assert.equal(scopedProcedureConfirmed.chosenScope, 'global', '人确认后带机器条件的流程可进全局层');
|
|
1168
|
-
assert.equal(scopedProcedureConfirmed.reasonCode, 'machine_scoped_procedure');
|
|
1169
|
-
|
|
1170
|
-
const scopedExplicit = store.routeScope({
|
|
1171
|
-
title: 'npm 操作走 dsh-npm', summary: '本机 npm 命令用包装脚本',
|
|
1172
|
-
body: '本机 npm 一律用 dsh-npm;裸 npm 报 ENEEDAUTH 不是缺凭据',
|
|
1173
|
-
memoryClass: 'portable_procedure', scope: 'global',
|
|
1174
|
-
conditions: [{ key: 'machine', value: 'macOS 桌面机' }],
|
|
1175
|
-
}, { project });
|
|
1176
|
-
assert.equal(scopedExplicit.chosenScope, 'global');
|
|
1177
|
-
assert.equal(scopedExplicit.reasonCode, 'explicit');
|
|
1178
|
-
|
|
1179
|
-
const unconfirmed = store.routeScope({
|
|
1180
|
-
title: '部署脚本', summary: '本机部署脚本', body: 'bash /tmp/deploy.sh',
|
|
1181
|
-
memoryClass: 'portable_procedure', scope: 'global',
|
|
1182
|
-
}, { project: { confirmed: false } });
|
|
1183
|
-
assert.equal(unconfirmed.needsScope, true);
|
|
1184
|
-
assert.equal(unconfirmed.reasonCode, 'needs_environment');
|
|
1185
|
-
assert.deepEqual(unconfirmed.requiredConditions.map((row) => row.key), ['machine', 'runtime']);
|
|
1186
|
-
|
|
1187
|
-
const humanGlobal = store.routeScope({
|
|
1188
|
-
title: '部署脚本', summary: '本机部署脚本', body: 'bash /tmp/deploy.sh',
|
|
1189
|
-
memoryClass: 'portable_procedure', scope: 'global',
|
|
1190
|
-
}, { project, humanScope: true });
|
|
1191
|
-
assert.equal(humanGlobal.chosenScope, 'global');
|
|
1192
|
-
assert.equal(humanGlobal.reasonCode, 'explicit');
|
|
1193
|
-
|
|
1194
|
-
const modelGlobalFact = store.routeScope({
|
|
1195
|
-
title: '发布分支', summary: '本仓库发布分支是 main', body: 'main',
|
|
1196
|
-
memoryClass: 'project_fact', scope: 'global',
|
|
1197
|
-
}, { project });
|
|
1198
|
-
assert.equal(modelGlobalFact.chosenScope, 'project', '模型自填 global 不能把项目事实送进全局层');
|
|
1199
|
-
assert.equal(modelGlobalFact.reasonCode, 'model_global_project_fact');
|
|
1200
|
-
|
|
1201
|
-
/** 显式 global 是用户要的那一层:没 evidenceRef 也只代表模型没补依据,不能把它降回项目层。 */
|
|
1202
|
-
const userGlobalProcedure = store.routeScope({
|
|
1203
|
-
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1204
|
-
memoryClass: 'portable_procedure', scope: 'global',
|
|
1205
|
-
}, { project });
|
|
1206
|
-
assert.equal(userGlobalProcedure.chosenScope, 'global');
|
|
1207
|
-
assert.equal(userGlobalProcedure.reasonCode, 'explicit');
|
|
1208
|
-
|
|
1209
|
-
/** 没显式 scope 时,memoryClass 只是提案:缺可搬依据的流程仍留在项目层。 */
|
|
1210
|
-
const classOnlyProcedure = store.routeScope({
|
|
1211
|
-
title: '提交前跑测试', summary: '提交前跑测试', body: 'pnpm test',
|
|
1212
|
-
memoryClass: 'portable_procedure',
|
|
1213
|
-
}, { project });
|
|
1214
|
-
assert.equal(classOnlyProcedure.chosenScope, 'project', '没有显式 scope 又缺依据,不能自己搬去全局层');
|
|
1215
|
-
assert.equal(classOnlyProcedure.reasonCode, 'unbound_evidence');
|
|
1216
|
-
});
|
|
1217
|
-
|
|
1218
|
-
test('几百条归档的正文不进热状态,下一次保存照样能过', () => {
|
|
1219
|
-
const scope = fresh('cold-archives');
|
|
1220
|
-
fs.mkdirSync(scope.dir, { recursive: true });
|
|
1221
|
-
const at = new Date(Date.now() - 86_400_000).toISOString();
|
|
1222
|
-
const row = (id, status, title, body) => ({
|
|
1223
|
-
id,
|
|
1224
|
-
revision: 1,
|
|
1225
|
-
status,
|
|
1226
|
-
origin: 'user_explicit',
|
|
1227
|
-
createdAt: at,
|
|
1228
|
-
updatedAt: at,
|
|
1229
|
-
current: {
|
|
1230
|
-
title, summary: title, body, kind: 'note', when: '', keywords: [], topicKey: '',
|
|
1231
|
-
triggers: [], applicability: 'all_in_scope', conditions: [], selection: null, links: [], aliases: [],
|
|
1232
|
-
},
|
|
1233
|
-
maintenance: { reviewState: 'current', sourceRefs: [], retiredNames: [], versions: [], checks: [], hits: 0, merges: 0, lastUsed: at },
|
|
1234
|
-
});
|
|
1235
|
-
const build = (chars) => {
|
|
1236
|
-
const big = state.emptyState(scope);
|
|
1237
|
-
big.entries.push(row('mem_cold00000001', 'active', '现役条目', '现役正文'));
|
|
1238
|
-
const body = '归档正文示例'.repeat(1).padEnd(1) + '归'.repeat(chars);
|
|
1239
|
-
for (let i = 0; i < 480; i += 1) {
|
|
1240
|
-
big.entries.push(row(`mem_cold${String(i + 2).padStart(8, '0')}`, 'archived', `旧条目 ${i}`, body));
|
|
1241
|
-
}
|
|
1242
|
-
return state.encodeState(big);
|
|
1243
|
-
};
|
|
1244
|
-
let chars = 1400;
|
|
1245
|
-
let encoded = build(chars);
|
|
1246
|
-
for (let attempt = 0; attempt < 30 && Buffer.byteLength(encoded) > LIMITS.stateBytes - 1200; attempt += 1) {
|
|
1247
|
-
const over = Buffer.byteLength(encoded) - (LIMITS.stateBytes - 1200);
|
|
1248
|
-
chars -= Math.max(1, Math.ceil(over / (480 * 3)));
|
|
1249
|
-
encoded = build(chars);
|
|
1250
|
-
}
|
|
1251
|
-
const seeded = Buffer.byteLength(encoded);
|
|
1252
|
-
assert.ok(seeded <= LIMITS.stateBytes, '造出的热状态本身还得读得动');
|
|
1253
|
-
assert.ok(seeded > LIMITS.stateBytes - 3200, '离上限只差一条新记忆');
|
|
1254
|
-
fs.writeFileSync(path.join(scope.dir, 'state.json'), encoded);
|
|
1255
|
-
|
|
1256
|
-
const saved = upsert(scope, { title: '新的一条', summary: '归档再多也要能写', body: '新正文'.repeat(400) });
|
|
1257
|
-
assert.equal(saved.record.title, '新的一条');
|
|
1258
|
-
assert.ok(Buffer.byteLength(rawState(scope)) < LIMITS.stateBytes, '热状态回到上限内');
|
|
1259
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'cold')), true);
|
|
1260
|
-
assert.equal(fs.existsSync(path.join(scope.dir, 'archive')), false);
|
|
1261
|
-
const archivedId = 'mem_cold00000002';
|
|
1262
|
-
assert.match(readEntry(scope, archivedId).body, /归档正文示例/, '归档正文还能读到');
|
|
1263
|
-
assert.match(search(scope, '新的一条')[0].title, /新的一条/);
|
|
1264
|
-
});
|
|
1265
|
-
|
|
1266
|
-
test('lastUsed 只在真读过且够旧时回写,刚存下的条目不动', () => {
|
|
1267
|
-
const scope = fresh('last-used-touch');
|
|
1268
|
-
const created = upsert(scope, { title: '会被读的条目', summary: '读一次要记下用过', body: '正文' });
|
|
1269
|
-
const id = created.record.id;
|
|
1270
|
-
const freshUsed = loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed;
|
|
1271
|
-
readEntry(scope, id);
|
|
1272
|
-
assert.equal(loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed, freshUsed, '十分钟内不写盘');
|
|
1273
|
-
search(scope, '会被读的条目');
|
|
1274
|
-
assert.equal(loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed, freshUsed);
|
|
1275
|
-
|
|
1276
|
-
const stale = new Date(Date.now() - 11 * 60 * 1000).toISOString();
|
|
1277
|
-
const index = loadIndex(scope);
|
|
1278
|
-
index.entries.find((entry) => entry.id === id).lastUsed = stale;
|
|
1279
|
-
store.saveIndex(scope, index);
|
|
1280
|
-
assert.equal(stateOf(scope).entries.find((entry) => entry.id === id).maintenance.lastUsed, stale);
|
|
1281
|
-
readEntry(scope, id);
|
|
1282
|
-
const touched = loadIndex(scope).entries.find((entry) => entry.id === id).lastUsed;
|
|
1283
|
-
assert.notEqual(touched, stale);
|
|
1284
|
-
assert.ok(Date.now() - Date.parse(touched) < 60_000, '过旧时读一次就回写');
|
|
1285
|
-
|
|
1286
|
-
const other = upsert(scope, { title: '葡萄柚检索目标', summary: '葡萄柚专用的检索记录', body: '葡萄柚正文' });
|
|
1287
|
-
assert.ok(other.record, '第二条要真的新建出来');
|
|
1288
|
-
const staleSearch = new Date(Date.now() - 11 * 60 * 1000).toISOString();
|
|
1289
|
-
const again = loadIndex(scope);
|
|
1290
|
-
again.entries.find((entry) => entry.id === other.record.id).lastUsed = staleSearch;
|
|
1291
|
-
store.saveIndex(scope, again);
|
|
1292
|
-
search(scope, '葡萄柚检索目标');
|
|
1293
|
-
assert.notEqual(loadIndex(scope).entries.find((entry) => entry.id === other.record.id).lastUsed, staleSearch);
|
|
1294
|
-
});
|
|
1295
|
-
|
|
1296
|
-
test('项目层未绑作品的候选也能开始整理并提交成规则', () => {
|
|
1297
|
-
const scope = fresh('learning-unbound');
|
|
1298
|
-
const on = setEvolution(scope, true, { operationId: 'evo-unbound-on' });
|
|
1299
|
-
assert.equal(on.ok, true);
|
|
1300
|
-
const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'unbound-start' });
|
|
1301
|
-
assert.equal(started.ok, true, started.reason);
|
|
1302
|
-
assert.equal(started.episodeId, '', '不编造作品 id');
|
|
1303
|
-
const change = {
|
|
1304
|
-
target: 'new', feedbackType: 'spec_change', topicKey: '口播开头',
|
|
1305
|
-
currentText: '开头三句内给结论。', applicability: 'conditional',
|
|
1306
|
-
conditions: [{ key: 'seriesId', value: '口播' }], evidence: ['u0'], persistence: 'project_candidate',
|
|
1307
|
-
};
|
|
1308
|
-
const committed = store.commitLearning(scope, {
|
|
1309
|
-
operationId: 'unbound-commit', epoch: started.epoch, episodeId: '', projectId: scope.key,
|
|
1310
|
-
sessionId: 's-unbound', messageIds: ['m1'], changes: [change], preparedTargets: [],
|
|
1311
|
-
});
|
|
1312
|
-
assert.equal(committed.ok, true, committed.reason);
|
|
1313
|
-
const rules = stateOf(scope).entries.filter((entry) => entry.origin === 'project_evolution');
|
|
1314
|
-
assert.equal(rules.length, 1);
|
|
1315
|
-
assert.match(rules[0].current.title, /开头|口播/);
|
|
1316
|
-
|
|
1317
|
-
const foreign = store.commitLearning(scope, {
|
|
1318
|
-
operationId: 'unbound-foreign', epoch: started.epoch, episodeId: '', projectId: 'other-project',
|
|
1319
|
-
sessionId: 's-unbound', messageIds: ['m1'], changes: [change], preparedTargets: [],
|
|
1320
|
-
});
|
|
1321
|
-
assert.equal(foreign.ok, false);
|
|
1322
|
-
assert.match(foreign.reason, /缺少作品标识/);
|
|
1323
|
-
});
|
|
1324
|
-
|
|
1325
|
-
test('整理回执的 evidence 带上用户原句,优先本次改动其次整批', () => {
|
|
1326
|
-
const changeFor = (extra = {}) => ({
|
|
1327
|
-
target: 'new', feedbackType: 'spec_change', topicKey: '口播开头',
|
|
1328
|
-
currentText: '开头三句内给结论。', applicability: 'conditional',
|
|
1329
|
-
conditions: [{ key: 'seriesId', value: '口播' }], evidence: ['u0'], persistence: 'project_candidate',
|
|
1330
|
-
...extra,
|
|
1331
|
-
});
|
|
1332
|
-
const commitOnce = (scope, batchExcerpt, changeExcerpt) => {
|
|
1333
|
-
assert.equal(setEvolution(scope, true, { operationId: `evo-evidence-${scope.key}` }).ok, true);
|
|
1334
|
-
const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: `start-evidence-${scope.key}` });
|
|
1335
|
-
assert.equal(started.ok, true, started.reason);
|
|
1336
|
-
const committed = store.commitLearning(scope, {
|
|
1337
|
-
operationId: `commit-evidence-${scope.key}`, epoch: started.epoch, episodeId: '', projectId: scope.key,
|
|
1338
|
-
sessionId: 's-evidence', messageIds: ['m1', 'm2'],
|
|
1339
|
-
sourceExcerpt: batchExcerpt,
|
|
1340
|
-
changes: [changeFor(changeExcerpt ? { sourceExcerpt: changeExcerpt } : {})], preparedTargets: [],
|
|
1341
|
-
});
|
|
1342
|
-
assert.equal(committed.ok, true, committed.reason);
|
|
1343
|
-
return loadIndex(scope).entries.find((entry) => entry.origin === 'project_evolution');
|
|
1344
|
-
};
|
|
1345
|
-
const fromBatch = commitOnce(fresh('evidence-batch'), '用户原话:开头别绕,三句内给结论。');
|
|
1346
|
-
assert.equal(fromBatch.learning.sourceExcerpt, '用户原话:开头别绕,三句内给结论。');
|
|
1347
|
-
assert.deepEqual(fromBatch.learning.sourceRefs.at(-1).messageIds, ['m1', 'm2'], 'messageIds 仍是真实消息');
|
|
1348
|
-
const fromChange = commitOnce(fresh('evidence-change'), '整批摘录不该优先', '用户原话:开头三句内给结论,别铺垫。');
|
|
1349
|
-
assert.equal(fromChange.learning.sourceExcerpt, '用户原话:开头三句内给结论,别铺垫。', '本次改动摘录优先');
|
|
1350
|
-
});
|
|
1351
|
-
|
|
1352
|
-
test('整理器规则顺序与开始整理时一致,按状态/最近使用排而不是插入顺序', () => {
|
|
1353
|
-
const scope = fresh('organizer-order');
|
|
1354
|
-
const on = setEvolution(scope, true, { operationId: 'order-evo-on' });
|
|
1355
|
-
assert.equal(on.ok, true, on.reason);
|
|
1356
|
-
const started = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'order-start' });
|
|
1357
|
-
assert.equal(started.ok, true, started.reason);
|
|
1358
|
-
const changes = ['先写的旧规则', '后写的新规则'].map((topic) => ({
|
|
1359
|
-
target: 'new', feedbackType: 'spec_change', topicKey: topic,
|
|
1360
|
-
currentText: `${topic}:按规则走。`, applicability: 'all_in_scope',
|
|
1361
|
-
evidence: ['u0'], persistence: 'project_candidate',
|
|
1362
|
-
}));
|
|
1363
|
-
const committed = store.commitLearning(scope, {
|
|
1364
|
-
operationId: 'order-commit', epoch: started.epoch, projectId: scope.key,
|
|
1365
|
-
sessionId: 's-order', messageIds: ['m1'], changes, preparedTargets: [],
|
|
1366
|
-
});
|
|
1367
|
-
assert.equal(committed.ok, true, committed.reason);
|
|
1368
|
-
const organizer = store.learningRulesForOrganizer(scope).map((row) => row.id);
|
|
1369
|
-
assert.equal(organizer.length, 2);
|
|
1370
|
-
assert.deepEqual(store.learningRulesForOrganizer(scope).map((row) => row.id), organizer, '顺序确定,重复调用不变');
|
|
1371
|
-
const again = store.beginLearningAttempt(scope, { episodeId: '', operationId: 'order-start-2' });
|
|
1372
|
-
assert.equal(again.ok, true, again.reason);
|
|
1373
|
-
assert.deepEqual(again.rules.map((row) => row.id), organizer, '两处必须同一个顺序');
|
|
1374
|
-
});
|
|
1375
|
-
|
|
1376
|
-
/** 冷存是归档正文唯一的家:写入必须和 state.json 一样 fsync 文件 + 目录,失败要整次保存失败。 */
|
|
1377
|
-
const traceDurability = () => {
|
|
1378
|
-
const opened = new Map();
|
|
1379
|
-
const synced = [];
|
|
1380
|
-
const realOpen = fs.openSync;
|
|
1381
|
-
const realFsync = fs.fsyncSync;
|
|
1382
|
-
fs.openSync = (file, flags, ...rest) => {
|
|
1383
|
-
const fd = realOpen.call(fs, file, flags, ...rest);
|
|
1384
|
-
opened.set(fd, String(file));
|
|
1385
|
-
return fd;
|
|
1386
|
-
};
|
|
1387
|
-
fs.fsyncSync = (fd) => {
|
|
1388
|
-
synced.push(opened.get(fd) || '');
|
|
1389
|
-
return realFsync.call(fs, fd);
|
|
1390
|
-
};
|
|
1391
|
-
return {
|
|
1392
|
-
opened,
|
|
1393
|
-
synced,
|
|
1394
|
-
restore() {
|
|
1395
|
-
fs.openSync = realOpen;
|
|
1396
|
-
fs.fsyncSync = realFsync;
|
|
1397
|
-
},
|
|
1398
|
-
};
|
|
1399
|
-
};
|
|
1400
|
-
|
|
1401
|
-
test('归档正文写冷存时 fsync 文件和目录(与 state.json 同一落盘序列)', () => {
|
|
1402
|
-
const scope = fresh('cold-fsync-trace');
|
|
1403
|
-
const created = upsert(scope, { title: '落盘冷存', summary: '冷存落盘序列', kind: 'note' }, {});
|
|
1404
|
-
const trace = traceDurability();
|
|
1405
|
-
try {
|
|
1406
|
-
const result = forget(scope, created.record.id, { mode: 'archive' });
|
|
1407
|
-
assert.equal(result.mode, 'archive');
|
|
1408
|
-
} finally {
|
|
1409
|
-
trace.restore();
|
|
1410
|
-
}
|
|
1411
|
-
const coldDir = path.join(scope.dir, 'cold');
|
|
1412
|
-
assert.ok(trace.synced.some((file) => file === coldDir), `冷存目录要 fsync:${JSON.stringify(trace.synced)}`);
|
|
1413
|
-
assert.ok(trace.synced.some((file) => file.startsWith(path.join(coldDir, '.')) && file.endsWith('.tmp')),
|
|
1414
|
-
`冷存临时文件要先 fsync 再 rename:${JSON.stringify(trace.synced)}`);
|
|
1415
|
-
const file = path.join(coldDir, `${created.record.id}.json`);
|
|
1416
|
-
assert.equal(fs.existsSync(file), true);
|
|
1417
|
-
assert.equal(fs.existsSync(`${file}.tmp`), false);
|
|
1418
|
-
});
|
|
1419
|
-
|
|
1420
|
-
test('冷存 fsync 失败时保存失败、不发布状态、不留半个冷存文件', () => {
|
|
1421
|
-
const scope = fresh('cold-fsync-fail');
|
|
1422
|
-
const body = '归档正文必须能读回来';
|
|
1423
|
-
const created = upsert(scope, { title: '写不进去的冷存', summary: '冷存 fsync 失败', body, kind: 'note' }, {});
|
|
1424
|
-
const trace = traceDurability();
|
|
1425
|
-
const coldDir = path.join(scope.dir, 'cold');
|
|
1426
|
-
try {
|
|
1427
|
-
const opened = trace.opened;
|
|
1428
|
-
const real = fs.fsyncSync;
|
|
1429
|
-
fs.fsyncSync = (fd) => {
|
|
1430
|
-
const file = opened.get(fd) || '';
|
|
1431
|
-
if (file.startsWith(path.join(coldDir, '.')) && file.endsWith('.tmp')) {
|
|
1432
|
-
const error = new Error('injected fsync failure');
|
|
1433
|
-
error.code = 'EIO';
|
|
1434
|
-
throw error;
|
|
1435
|
-
}
|
|
1436
|
-
return real.call(fs, fd);
|
|
1437
|
-
};
|
|
1438
|
-
assert.throws(() => forget(scope, created.record.id, { mode: 'archive' }),
|
|
1439
|
-
(error) => error.code === 'unavailable' && /归档正文没写进冷存/.test(error.message));
|
|
1440
|
-
} finally {
|
|
1441
|
-
trace.restore();
|
|
1442
|
-
}
|
|
1443
|
-
assert.equal(fs.existsSync(path.join(coldDir, `${created.record.id}.json`)), false, 'fsync 没成功就不能有权威冷存文件');
|
|
1444
|
-
const tmpLeft = fs.existsSync(coldDir) ? fs.readdirSync(coldDir).filter((name) => name.endsWith('.tmp')) : [];
|
|
1445
|
-
assert.deepEqual(tmpLeft, [], '失败的临时文件要清掉');
|
|
1446
|
-
assert.equal(bodyOf(scope, created.record.id), body, '发布失败时热状态原样保留正文');
|
|
1447
|
-
const retried = forget(scope, created.record.id, { mode: 'archive' });
|
|
1448
|
-
assert.equal(retried.mode, 'archive');
|
|
1449
|
-
const healed = readEntry(scope, created.record.id);
|
|
1450
|
-
assert.equal(healed.status, 'archived');
|
|
1451
|
-
assert.equal(healed.body, body);
|
|
1452
|
-
});
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
test('一条冷存文件损坏不再拖死整层写入:退回热正文并重写好文件', () => {
|
|
1456
|
-
const scope = fresh('cold-corrupt-heal');
|
|
1457
|
-
const body = '归档正文还能从热状态找回来';
|
|
1458
|
-
const created = upsert(scope, { title: '冷存坏了', summary: '损坏自愈', body, kind: 'note' }, {});
|
|
1459
|
-
const coldFile = path.join(scope.dir, 'cold', `${created.record.id}.json`);
|
|
1460
|
-
fs.mkdirSync(path.dirname(coldFile), { recursive: true });
|
|
1461
|
-
fs.writeFileSync(coldFile, '{"broken":');
|
|
1462
|
-
const index = loadIndex(scope);
|
|
1463
|
-
/** 模拟预算腾位:正文还在热状态,索引里只留归档占位(没有 coldBody,必须去读冷存)。 */
|
|
1464
|
-
index.archived = [{ id: created.record.id, title: '冷存坏了', status: 'archived' }];
|
|
1465
|
-
index.entries = index.entries.filter((row) => row.id !== created.record.id);
|
|
1466
|
-
saveIndex(scope, index);
|
|
1467
|
-
const healed = readEntry(scope, created.record.id);
|
|
1468
|
-
assert.equal(healed.status, 'archived');
|
|
1469
|
-
assert.equal(healed.body, body, '自愈后正文要读得回来');
|
|
1470
|
-
assert.equal(JSON.parse(fs.readFileSync(coldFile, 'utf8')).body, body, '坏文件要被重写成好文件');
|
|
1471
|
-
});
|
|
1472
|
-
|
|
1473
|
-
test('冷存坏了且热状态也没有正文:仍然明确报 cold_corrupt,不冒充空正文', () => {
|
|
1474
|
-
const scope = fresh('cold-corrupt-fail');
|
|
1475
|
-
upsert(scope, { title: '占位', summary: '保持状态存在', body: '正常正文', kind: 'note' });
|
|
1476
|
-
const orphan = 'mem_999999999999';
|
|
1477
|
-
const coldFile = path.join(scope.dir, 'cold', `${orphan}.json`);
|
|
1478
|
-
fs.mkdirSync(path.dirname(coldFile), { recursive: true });
|
|
1479
|
-
fs.writeFileSync(coldFile, 'not json');
|
|
1480
|
-
const index = loadIndex(scope);
|
|
1481
|
-
index.archived = [{ id: orphan, title: '孤儿归档', status: 'archived' }];
|
|
1482
|
-
assert.throws(() => saveIndex(scope, index), (error) => error.code === 'cold_corrupt');
|
|
1483
|
-
assert.equal(fs.readFileSync(coldFile, 'utf8'), 'not json', '救不回来时不动那个坏文件');
|
|
1484
|
-
assert.equal(index.entries.length, 1, '失败时不能悄悄丢掉别的条目');
|
|
1485
|
-
});
|
|
1486
|
-
|
|
1487
|
-
console.log(`store.test.mjs: ${passed} passed`);
|