@kira905/dsh-memory-toolkit 0.0.0-stage → 0.1.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/CHANGELOG.md +42 -0
- package/LICENSE +661 -0
- package/README.en.md +110 -0
- package/README.md +102 -2
- package/dsh-memory-local/FORK-BASE.json +24 -0
- package/dsh-memory-local/README.md +113 -0
- package/dsh-memory-local/build.mjs +101 -0
- package/dsh-memory-local/cordis.patch.yml +20 -0
- package/dsh-memory-local/deploy.mjs +306 -0
- package/dsh-memory-local/entity-status.mjs +75 -0
- package/dsh-memory-local/lib/bridge.mjs +108 -0
- package/dsh-memory-local/lib/host-adapter.js +1171 -0
- package/dsh-memory-local/lib/index.d.ts +128 -0
- package/dsh-memory-local/lib/index.js +944 -0
- package/dsh-memory-local/lib/injector.mjs +400 -0
- package/dsh-memory-local/lib/memory-read.mjs +116 -0
- package/dsh-memory-local/lib/module-gate.mjs +68 -0
- package/dsh-memory-local/lib/pointer.mjs +152 -0
- package/dsh-memory-local/lib/prod-gate.mjs +121 -0
- package/dsh-memory-local/lib/recall-log.mjs +118 -0
- package/dsh-memory-local/lib/recall.mjs +192 -0
- package/dsh-memory-local/lib/schema-migrate.mjs +365 -0
- package/dsh-memory-local/lib/user-message.mjs +32 -0
- package/dsh-memory-local/lib/vectors-read.mjs +110 -0
- package/dsh-memory-local/package.json +51 -0
- package/dsh-memory-local/restore.mjs +259 -0
- package/dsh-memory-local/rollback-injector-fix.mjs +166 -0
- package/dsh-memory-local/src/bridge.mjs +108 -0
- package/dsh-memory-local/src/host-adapter.js +1171 -0
- package/dsh-memory-local/src/index.d.ts +128 -0
- package/dsh-memory-local/src/index.js +944 -0
- package/dsh-memory-local/src/injector.mjs +400 -0
- package/dsh-memory-local/src/memory-read.mjs +116 -0
- package/dsh-memory-local/src/module-gate.mjs +68 -0
- package/dsh-memory-local/src/pointer.mjs +152 -0
- package/dsh-memory-local/src/recall-log.mjs +118 -0
- package/dsh-memory-local/src/recall.mjs +192 -0
- package/dsh-memory-local/src/schema-migrate.mjs +365 -0
- package/dsh-memory-local/src/user-message.mjs +32 -0
- package/dsh-memory-local/src/vectors-read.mjs +110 -0
- package/dsh-memory-local/test/fixtures-s2b.mjs +216 -0
- package/dsh-memory-local/test/probe-entity.mjs +170 -0
- package/dsh-memory-local/test/regression.mjs +270 -0
- package/dsh-memory-local/test/s2b.test.mjs +568 -0
- package/dsh-memory-local/test/schema-migrate.test.mjs +334 -0
- package/dsh-memory-local/test/test-adapter-converge.mjs +127 -0
- package/dsh-memory-local/test/visible-injections.test.mjs +239 -0
- package/dsh-memory-local/verify-after-restart.mjs +181 -0
- package/lib/deploy-guard.mjs +391 -0
- package/lib/guard-http.mjs +93 -0
- package/lib/pitfall-form-map.mjs +130 -0
- package/lib/pitfall-score.mjs +185 -0
- package/lib/session-file-name.mjs +160 -0
- package/lib/zstd-frames.mjs +78 -0
- package/memory-db-tools/README.md +68 -0
- package/memory-db-tools/_test/test-memory-scope.mjs +302 -0
- package/memory-db-tools/audit-analyze.mjs +352 -0
- package/memory-db-tools/audit-dump-ids.mjs +245 -0
- package/memory-db-tools/audit-export.mjs +270 -0
- package/memory-db-tools/audit-hit-probe.mjs +227 -0
- package/memory-db-tools/audit-ledger-probe.mjs +236 -0
- package/memory-db-tools/audit-persona-overlap.mjs +277 -0
- package/memory-db-tools/audit-struct-probe.mjs +213 -0
- package/memory-db-tools/audit-verify-unchanged.mjs +276 -0
- package/memory-db-tools/check-memory-db.mjs +8 -0
- package/memory-db-tools/dump-memory-db.mjs +12 -0
- package/memory-db-tools/lib/memory-scope.mjs +446 -0
- package/memory-db-tools/list-memory-summary.mjs +477 -0
- package/memory-db-tools/memory-identity-check.mjs +101 -0
- package/memory-db-tools/memory-schema-migrate.mjs +290 -0
- package/memory-db-tools/memory-scope-fixture.mjs +197 -0
- package/memory-db-tools/read-memories.mjs +26 -0
- package/memory-db-tools/read-memory-db.mjs +20 -0
- package/memory-db-tools/refine-memories.mjs +247 -0
- package/memory-db-tools/show-mem-times.mjs +22 -0
- package/memory-engine/README.md +70 -0
- package/memory-engine/bench/bench.mjs +221 -0
- package/memory-engine/cli/distill-rollback.mjs +86 -0
- package/memory-engine/cli/distill.mjs +156 -0
- package/memory-engine/cli/doc-index-rebuild.mjs +155 -0
- package/memory-engine/cli/doc-index.mjs +48 -0
- package/memory-engine/cli/domain-tags.mjs +251 -0
- package/memory-engine/cli/pitfall-hook.mjs +91 -0
- package/memory-engine/cli/recall.mjs +132 -0
- package/memory-engine/cli/selfcheck.mjs +57 -0
- package/memory-engine/cli/serve-start.mjs +247 -0
- package/memory-engine/cli/sweep.mjs +58 -0
- package/memory-engine/cli/sync.mjs +69 -0
- package/memory-engine/lib/cli-util.mjs +197 -0
- package/memory-engine/lib/config.mjs +135 -0
- package/memory-engine/lib/dirty-ledger.mjs +174 -0
- package/memory-engine/lib/distill-rollback.mjs +178 -0
- package/memory-engine/lib/distill.mjs +898 -0
- package/memory-engine/lib/doc-index-store.mjs +325 -0
- package/memory-engine/lib/doc-index.mjs +529 -0
- package/memory-engine/lib/doc-scan.mjs +271 -0
- package/memory-engine/lib/doc-summarize.mjs +305 -0
- package/memory-engine/lib/domain-tags.mjs +429 -0
- package/memory-engine/lib/embedder.mjs +242 -0
- package/memory-engine/lib/hashes.mjs +12 -0
- package/memory-engine/lib/memory-source.mjs +127 -0
- package/memory-engine/lib/pitfall-source.mjs +170 -0
- package/memory-engine/lib/policy-source.mjs +371 -0
- package/memory-engine/lib/recall.mjs +331 -0
- package/memory-engine/lib/selfcheck.mjs +131 -0
- package/memory-engine/lib/sweep.mjs +159 -0
- package/memory-engine/lib/sync.mjs +180 -0
- package/memory-engine/lib/tick-hook.mjs +218 -0
- package/memory-engine/lib/vectors-store.mjs +292 -0
- package/memory-engine/package-lock.json +1086 -0
- package/memory-engine/package.json +22 -0
- package/memory-engine/serve.mjs +171 -0
- package/memory-engine/test/01-store.test.mjs +127 -0
- package/memory-engine/test/02-sync.test.mjs +196 -0
- package/memory-engine/test/03-sweep.test.mjs +145 -0
- package/memory-engine/test/04-recall.test.mjs +214 -0
- package/memory-engine/test/05-selfcheck.test.mjs +175 -0
- package/memory-engine/test/06-doc-index.test.mjs +928 -0
- package/memory-engine/test/07-domain-tags.test.mjs +298 -0
- package/memory-engine/test/08-distill.test.mjs +686 -0
- package/memory-engine/test/09-pitfall-source.test.mjs +365 -0
- package/memory-engine/test/10-tick-hook.test.mjs +252 -0
- package/memory-engine/test/11-policy-source.test.mjs +224 -0
- package/memory-engine/test/_tickhook-worker.mjs +56 -0
- package/memory-engine/test/fixtures.mjs +205 -0
- package/memory-engine/test/harness.mjs +73 -0
- package/memory-engine/test/run.mjs +35 -0
- package/memory-engine/tools/bridge-smoke.mjs +80 -0
- package/memory-engine/tools/fetch-model.mjs +128 -0
- package/memory-engine/tools/smoke-recall.mjs +76 -0
- package/package.json +53 -4
- package/persona-memory-audit.mjs +217 -0
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
// 06 · 文档索引层(独立库 / 扫描面 / 粒度 / 幂等 / 变更感知 / 锁与重试 / 成本闸 / 重建一致)
|
|
2
|
+
//
|
|
3
|
+
// 铁律:夹具一律落 os.tmpdir(),库/state/日报全在临时根下 —— 绝不碰 ~/.dsh 与生产 docs。
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
+
import { test, assert, assertEq } from './harness.mjs';
|
|
9
|
+
import {
|
|
10
|
+
COLUMNS,
|
|
11
|
+
META_KEYS,
|
|
12
|
+
getMeta,
|
|
13
|
+
getMetaAll,
|
|
14
|
+
getRow,
|
|
15
|
+
listRows,
|
|
16
|
+
openDocIndexStore,
|
|
17
|
+
countRows,
|
|
18
|
+
isBusyError,
|
|
19
|
+
withBusyRetry,
|
|
20
|
+
upsertDocs,
|
|
21
|
+
deleteDoc,
|
|
22
|
+
escapeLike,
|
|
23
|
+
} from '../lib/doc-index-store.mjs';
|
|
24
|
+
import {
|
|
25
|
+
EXCLUDE_DIRS,
|
|
26
|
+
FIRST_BATCH,
|
|
27
|
+
LONG_DOC_CHARS,
|
|
28
|
+
SUMMARY_MAX_CHARS,
|
|
29
|
+
isExcludedRel,
|
|
30
|
+
parseStructure,
|
|
31
|
+
pickParagraph,
|
|
32
|
+
planRowCount,
|
|
33
|
+
planRows,
|
|
34
|
+
scanDocs,
|
|
35
|
+
splitSections,
|
|
36
|
+
toRelPath,
|
|
37
|
+
} from '../lib/doc-scan.mjs';
|
|
38
|
+
import {
|
|
39
|
+
LLM_LIMITS,
|
|
40
|
+
buildLlmView,
|
|
41
|
+
buildRuleSummary,
|
|
42
|
+
checkCostGate,
|
|
43
|
+
emptyCost,
|
|
44
|
+
parseLlmRows,
|
|
45
|
+
rolloverCost,
|
|
46
|
+
summarizeAll,
|
|
47
|
+
} from '../lib/doc-summarize.mjs';
|
|
48
|
+
import {
|
|
49
|
+
DAILY_SECTION_TITLE,
|
|
50
|
+
dailyReportPath,
|
|
51
|
+
needsRebuild,
|
|
52
|
+
renderDailyLine,
|
|
53
|
+
resolveScope,
|
|
54
|
+
runDocIndexOnce,
|
|
55
|
+
updateDailySection,
|
|
56
|
+
} from '../lib/doc-index.mjs';
|
|
57
|
+
|
|
58
|
+
export const __section = '06 · 文档索引层(独立库 / 扫描面 / 粒度 / 幂等 / 变更感知 / 锁重试 / 成本闸)';
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------- 夹具
|
|
61
|
+
|
|
62
|
+
/** 临时沙箱:docs 根 + 库 + state + 日报,全部在 tmp 下 */
|
|
63
|
+
function makeSandbox(prefix = 'dsh-doc-index-') {
|
|
64
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
65
|
+
const docsRoot = path.join(root, 'DSH', 'docs');
|
|
66
|
+
fs.mkdirSync(docsRoot, { recursive: true });
|
|
67
|
+
const paths = {
|
|
68
|
+
root,
|
|
69
|
+
dshRoot: path.join(root, 'DSH'),
|
|
70
|
+
docsRoot,
|
|
71
|
+
dbPath: path.join(root, 'memory-vectors', 'doc-index.db'),
|
|
72
|
+
stateFile: path.join(root, 'butler-doc-index-state.json'),
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
...paths,
|
|
76
|
+
write(rel, text) {
|
|
77
|
+
const abs = path.join(paths.dshRoot, rel);
|
|
78
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
79
|
+
fs.writeFileSync(abs, text, 'utf8');
|
|
80
|
+
return abs;
|
|
81
|
+
},
|
|
82
|
+
/** 改文档内容并把 mtime 推到未来(模拟"文档真的变了") */
|
|
83
|
+
touch(rel, text, plusMs = 5000) {
|
|
84
|
+
const abs = path.join(paths.dshRoot, rel);
|
|
85
|
+
fs.writeFileSync(abs, text, 'utf8');
|
|
86
|
+
const st = fs.statSync(abs);
|
|
87
|
+
const t = new Date(st.mtimeMs + plusMs);
|
|
88
|
+
fs.utimesSync(abs, t, t);
|
|
89
|
+
return abs;
|
|
90
|
+
},
|
|
91
|
+
flags(extra = {}) {
|
|
92
|
+
return {
|
|
93
|
+
'docs-root': paths.docsRoot,
|
|
94
|
+
'dsh-root': paths.dshRoot,
|
|
95
|
+
'doc-index-db': paths.dbPath,
|
|
96
|
+
'state-file': paths.stateFile,
|
|
97
|
+
'no-daily': true,
|
|
98
|
+
...extra,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
cleanup() {
|
|
102
|
+
try {
|
|
103
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
104
|
+
} catch {
|
|
105
|
+
/* ignore */
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 快跑的注入(跳过退避等待、busy_timeout 压到 ~30ms) */
|
|
112
|
+
const FAST = {
|
|
113
|
+
sleep: async () => {},
|
|
114
|
+
retryDelaysMs: [1, 1, 1],
|
|
115
|
+
openStore: (p, o) => openDocIndexStore(p, { ...o, busyTimeoutMs: 30 }),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
function mkDoc(rel, text) {
|
|
119
|
+
return { rel, chars: text.length, structure: parseStructure(text) };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const SHORT_DOC = `# 短文档 A
|
|
123
|
+
|
|
124
|
+
引言:这是一份短文档,用来说明主人与机器人的关系设定。
|
|
125
|
+
|
|
126
|
+
## 一、第一节标题
|
|
127
|
+
|
|
128
|
+
本节讲关系设定:主人 = 用户,机器人负责干活。
|
|
129
|
+
还有第二句不该出现。
|
|
130
|
+
|
|
131
|
+
## 二、第二节标题
|
|
132
|
+
|
|
133
|
+
本节讲工作环境:本机装在 D: 盘。
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
/** 长文档:> 2 万字、多个 H2(用于验证 2~5 条切分) */
|
|
137
|
+
function longDoc(sections = 8, sectionChars = 3000) {
|
|
138
|
+
const parts = ['# 长文档 L\n\n引言:这是一份超长文档用于验证切条。\n'];
|
|
139
|
+
for (let i = 1; i <= sections; i += 1) {
|
|
140
|
+
parts.push(`\n## 第 ${i} 节标题\n\n${`第 ${i} 节正文。`.repeat(Math.ceil(sectionChars / 8))}\n`);
|
|
141
|
+
}
|
|
142
|
+
return parts.join('');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---------------------------------------------------------------- 1. 独立库与表结构
|
|
146
|
+
|
|
147
|
+
test('建库:doc_index + meta 表按需创建,schemaVersion/hostId 自动落 meta', () => {
|
|
148
|
+
const s = makeSandbox();
|
|
149
|
+
try {
|
|
150
|
+
const store = openDocIndexStore(s.dbPath, { hostId: 'test-host' });
|
|
151
|
+
assertEq(store.schema.tables.doc_index, true, 'doc_index 表');
|
|
152
|
+
assertEq(store.schema.tables.meta, true, 'meta 表');
|
|
153
|
+
assert(store.schema.created.includes('doc_index'), '首次应报告新建 doc_index');
|
|
154
|
+
assertEq(getMeta(store.db, META_KEYS.schemaVersion), '1', 'schemaVersion');
|
|
155
|
+
assertEq(getMeta(store.db, META_KEYS.hostId), 'test-host', 'hostId');
|
|
156
|
+
const again = openDocIndexStore(s.dbPath, { hostId: 'other' });
|
|
157
|
+
assertEq(again.schema.created.length, 0, '重开不重建(幂等)');
|
|
158
|
+
assertEq(getMeta(again.db, META_KEYS.hostId), 'test-host', 'hostId 不被覆盖');
|
|
159
|
+
store.db.close();
|
|
160
|
+
again.db.close();
|
|
161
|
+
assert(fs.existsSync(s.dbPath), '库文件存在');
|
|
162
|
+
} finally {
|
|
163
|
+
s.cleanup();
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('表结构:与方案 §3.2 逐列一致(列名/类型/NOT NULL/默认值/主键)', () => {
|
|
168
|
+
const s = makeSandbox();
|
|
169
|
+
try {
|
|
170
|
+
const store = openDocIndexStore(s.dbPath);
|
|
171
|
+
const info = store.db.prepare('PRAGMA table_info(doc_index)').all();
|
|
172
|
+
assertEq(info.length, COLUMNS.length, '列数');
|
|
173
|
+
const actual = info.map((c) => ({
|
|
174
|
+
name: c.name,
|
|
175
|
+
type: c.type,
|
|
176
|
+
notnull: c.notnull,
|
|
177
|
+
dflt: c.dflt_value,
|
|
178
|
+
pk: c.pk,
|
|
179
|
+
}));
|
|
180
|
+
assertEq(actual, COLUMNS, '逐列比对');
|
|
181
|
+
store.db.close();
|
|
182
|
+
} finally {
|
|
183
|
+
s.cleanup();
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('meta:三键(schemaVersion / hostId / lastRunAt)在跑完一轮后齐备', async () => {
|
|
188
|
+
const s = makeSandbox();
|
|
189
|
+
try {
|
|
190
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
191
|
+
const r = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST, now: () => 1760000000000 });
|
|
192
|
+
assertEq(r.status, 'ok', '状态');
|
|
193
|
+
const store = openDocIndexStore(s.dbPath, { create: false });
|
|
194
|
+
const meta = getMetaAll(store.db);
|
|
195
|
+
assert(Boolean(meta.schemaVersion), 'schemaVersion');
|
|
196
|
+
assert(Boolean(meta.hostId), 'hostId');
|
|
197
|
+
assert(Boolean(meta.lastRunAt), 'lastRunAt');
|
|
198
|
+
store.db.close();
|
|
199
|
+
} finally {
|
|
200
|
+
s.cleanup();
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('UPSERT 幂等:同 path 覆盖不新增行;text_hash 相同则整条不重写', () => {
|
|
205
|
+
const s = makeSandbox();
|
|
206
|
+
try {
|
|
207
|
+
const store = openDocIndexStore(s.dbPath);
|
|
208
|
+
const base = { path: 'docs/a.md', mtime: 1, size: 10, textHash: 'h1', summary: 'S1', updatedAt: 1 };
|
|
209
|
+
const r1 = upsertDocs(store.db, [base]);
|
|
210
|
+
assertEq(r1, { inserted: 1, updated: 0, unchanged: 0 }, '首次插入');
|
|
211
|
+
const r2 = upsertDocs(store.db, [{ ...base, mtime: 2, summary: 'S1-again' }]);
|
|
212
|
+
assertEq(r2, { inserted: 0, updated: 0, unchanged: 1 }, '同哈希 ⇒ 不重写');
|
|
213
|
+
assertEq(countRows(store.db), 1, '条数不变');
|
|
214
|
+
assertEq(getRow(store.db, 'docs/a.md').mtime, 1, '同哈希时不更新 mtime');
|
|
215
|
+
const r3 = upsertDocs(store.db, [{ ...base, textHash: 'h2', summary: 'S2' }]);
|
|
216
|
+
assertEq(r3, { inserted: 0, updated: 1, unchanged: 0 }, '哈希变 ⇒ 更新');
|
|
217
|
+
store.db.close();
|
|
218
|
+
} finally {
|
|
219
|
+
s.cleanup();
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('默认值与多人口径:owner_user / visibility 落库可核对', () => {
|
|
224
|
+
const s = makeSandbox();
|
|
225
|
+
try {
|
|
226
|
+
const store = openDocIndexStore(s.dbPath);
|
|
227
|
+
upsertDocs(store.db, [{ path: 'docs/a.md', mtime: 1, size: 1, textHash: 'h', summary: 'S', updatedAt: 1 }]);
|
|
228
|
+
const row = getRow(store.db, 'docs/a.md');
|
|
229
|
+
assertEq(row.owner_user, 'LiJianXiao', 'owner_user 默认');
|
|
230
|
+
assertEq(row.visibility, 'private_owner', 'visibility 默认');
|
|
231
|
+
store.db.close();
|
|
232
|
+
} finally {
|
|
233
|
+
s.cleanup();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------- 2. 扫描面与粒度
|
|
238
|
+
|
|
239
|
+
test('扫描面:只收 .md,排除 变更流水/管家报告/坑库/_archive(按目录段)', () => {
|
|
240
|
+
const s = makeSandbox();
|
|
241
|
+
try {
|
|
242
|
+
s.write('docs/keep.md', '# keep\n');
|
|
243
|
+
s.write('docs/变更流水/2026-09.md', '# 流水\n');
|
|
244
|
+
s.write('docs/管家报告/2026-09-21-x.md', '# 日报\n');
|
|
245
|
+
s.write('docs/坑库/坑-sample.md', '# 坑\n');
|
|
246
|
+
s.write('docs/_archive/old.md', '# 归档\n');
|
|
247
|
+
s.write('docs/sub/keep2.md', '# keep2\n');
|
|
248
|
+
s.write('docs/sub/坑库/inner.md', '# 内层坑\n');
|
|
249
|
+
s.write('docs/notmd.txt', 'x');
|
|
250
|
+
const rels = scanDocs(s.docsRoot, { dshRoot: s.dshRoot }).map((d) => d.rel);
|
|
251
|
+
assertEq(rels, ['docs/keep.md', 'docs/sub/keep2.md'], '命中面');
|
|
252
|
+
assertEq(EXCLUDE_DIRS.length, 4, '排除目录 4 类');
|
|
253
|
+
assertEq(isExcludedRel('docs/坑库-a.md'), false, '文件名含"坑库"但非目录段 ⇒ 不排除');
|
|
254
|
+
assertEq(isExcludedRel('docs/坑库/a.md'), true, '目录段命中 ⇒ 排除');
|
|
255
|
+
} finally {
|
|
256
|
+
s.cleanup();
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('path 口径:相对 DSH 根、统一 / 分隔', () => {
|
|
261
|
+
const drv = path.parse(process.cwd()).root;
|
|
262
|
+
assertEq(toRelPath(path.join(drv, 'DSH', 'docs', 'a.md'), path.join(drv, 'DSH')), 'docs/a.md', '反斜杠归一');
|
|
263
|
+
assertEq(toRelPath(path.join(drv, 'DSH', 'docs', 'sub', 'b.md'), path.join(drv, 'DSH')), 'docs/sub/b.md', '子目录');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('结构解析:H1 作标题、引言与 `##` 小节分开、`###` 归入所属小节', () => {
|
|
267
|
+
const st = parseStructure(SHORT_DOC);
|
|
268
|
+
assertEq(st.title, '短文档 A', '标题');
|
|
269
|
+
assert(st.intro.includes('短文档'), '引言');
|
|
270
|
+
assertEq(st.sections.map((x) => x.title), ['一、第一节标题', '二、第二节标题'], '小节标题');
|
|
271
|
+
const st2 = parseStructure('# T\n\n## A\n\n### A1\n\n正文A1\n\n## B\n\n正文B\n');
|
|
272
|
+
assertEq(st2.sections.length, 2, '### 不另起小节');
|
|
273
|
+
assert(st2.sections[0].body.includes('A1'), '### 归入所属小节');
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('粒度:短文档 1 条(主键 = path);长文档按 ## 切 2~5 条(主键 = path#§N)', () => {
|
|
277
|
+
const short = mkDoc('docs/s.md', SHORT_DOC);
|
|
278
|
+
const rowsShort = planRows(short);
|
|
279
|
+
assertEq(rowsShort.length, 1, '短文档 1 条');
|
|
280
|
+
assertEq(rowsShort[0].key, 'docs/s.md', '短文档主键 = path');
|
|
281
|
+
|
|
282
|
+
const text = longDoc(8, 3000);
|
|
283
|
+
const long = mkDoc('docs/l.md', text);
|
|
284
|
+
assert(long.chars > LONG_DOC_CHARS, `夹具应超 ${LONG_DOC_CHARS} 字(实际 ${long.chars})`);
|
|
285
|
+
const rowsLong = planRows(long);
|
|
286
|
+
assert(rowsLong.length >= 2 && rowsLong.length <= 5, `长文档 2~5 条(实际 ${rowsLong.length})`);
|
|
287
|
+
assertEq(rowsLong.map((r) => r.key), rowsLong.map((_, i) => `docs/l.md#§${i + 1}`), '长文档主键 = path#§N');
|
|
288
|
+
const allSections = rowsLong.flatMap((r) => r.sections.map((s) => s.title));
|
|
289
|
+
assertEq(allSections, long.structure.sections.map((s) => s.title), '小节不丢不重(保序)');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('粒度边界:无 ## 小节的超长文档退 1 条(无可聚合对象);planRowCount 落在 2~5', () => {
|
|
293
|
+
assertEq(planRowCount(1000, 5), 1, '短文档 1 条');
|
|
294
|
+
assertEq(planRowCount(25000, 8), 2, '2 万字级 → 2 条');
|
|
295
|
+
assertEq(planRowCount(200000, 30), 5, '超长 → 上限 5 条');
|
|
296
|
+
assertEq(planRowCount(25000, 1), 1, '只有 1 个小节 ⇒ 退 1 条');
|
|
297
|
+
const rows = planRows(mkDoc('docs/x.md', `# T\n\n${'正文。'.repeat(6000)}`));
|
|
298
|
+
assertEq(rows.length, 1, '无 ## ⇒ 1 条');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('切分确定性:splitSections 分组不丢条、组数恰为 k', () => {
|
|
302
|
+
const secs = Array.from({ length: 7 }, (_, i) => ({ title: `s${i}`, chars: 10, body: '' }));
|
|
303
|
+
const g = splitSections(secs, 3);
|
|
304
|
+
assertEq(g.length, 3, '组数');
|
|
305
|
+
assertEq(g.flat().map((s) => s.title), secs.map((s) => s.title), '保序且不丢');
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ---------------------------------------------------------------- 3. 摘要生成
|
|
309
|
+
|
|
310
|
+
test('摘要:每条 ≤500 字,含标题/关键章节/要点,且确定性(两次逐字节相同)', () => {
|
|
311
|
+
const doc = mkDoc('docs/s.md', SHORT_DOC);
|
|
312
|
+
const row = planRows(doc)[0];
|
|
313
|
+
const a = buildRuleSummary(doc, row);
|
|
314
|
+
const b = buildRuleSummary(doc, row);
|
|
315
|
+
assertEq(a, b, '确定性');
|
|
316
|
+
assert(a.length <= SUMMARY_MAX_CHARS, `≤${SUMMARY_MAX_CHARS} 字(实际 ${a.length})`);
|
|
317
|
+
assert(a.includes('短文档 A'), '含标题');
|
|
318
|
+
assert(a.includes('关键章节:'), '含关键章节行');
|
|
319
|
+
assert(a.includes('一、第一节标题'), '关键小节名在');
|
|
320
|
+
assert(a.includes('要点:'), '含要点');
|
|
321
|
+
// 说明行取自真正文(H1 后的第一段),不取引用块/表格 —— 引用块场景见下一条用例
|
|
322
|
+
assert(a.includes('说明:引言:这是一份短文档'), '说明取自真正文');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('摘要:长文档分段后各条互不重复关键章节(分组边界清晰)', () => {
|
|
326
|
+
const doc = mkDoc('docs/l.md', longDoc(8, 3000));
|
|
327
|
+
const rows = planRows(doc);
|
|
328
|
+
const chapters = rows.map((r) => buildRuleSummary(doc, r).match(/关键章节:([^要]*)要点/)?.[1] ?? '');
|
|
329
|
+
assertEq(new Set(chapters).size, chapters.length, '各段关键章节不同');
|
|
330
|
+
for (const c of chapters) assert(c.trim().length > 0, '关键章节非空');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('摘要清洗:引用块/表格/分隔线不进正文(否则所有索引条长一样)', () => {
|
|
334
|
+
const body = '> ↑本主题总纲见 docs\\x.md\n\n---\n\n| a | b |\n|---|---|\n\n真正的正文在这里,讲的是部署流程。\n';
|
|
335
|
+
const p = pickParagraph(body);
|
|
336
|
+
assert(p.startsWith('真正的正文'), `应跳过引用块/分隔线/表格(实际:${p.slice(0, 30)})`);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('成本闸:调用/ token 任一达上限 ⇒ 拒绝', () => {
|
|
340
|
+
assertEq(checkCostGate(emptyCost('2026-09-21')).allowed, true, '空账放行');
|
|
341
|
+
assertEq(checkCostGate({ date: 'd', calls: LLM_LIMITS.callsPerDay, tokens: 0 }).allowed, false, '次数上限');
|
|
342
|
+
assertEq(checkCostGate({ date: 'd', calls: 0, tokens: LLM_LIMITS.tokensPerDay }).allowed, false, 'token 上限');
|
|
343
|
+
assertEq(rolloverCost({ date: '2026-09-20', calls: 99, tokens: 999 }, '2026-09-21'), emptyCost('2026-09-21'), '跨日重置');
|
|
344
|
+
assertEq(LLM_LIMITS.callsPerDay, 20, '§3.5 次数上限 20/日');
|
|
345
|
+
assertEq(LLM_LIMITS.tokensPerDay, 150000, '§3.5 token 上限 150K/日');
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test('LLM 通路:mock fetch 返回 JSON ⇒ 摘要来自模型;压缩视图不是全文', async () => {
|
|
349
|
+
const doc = mkDoc('docs/l.md', longDoc(4, 3000));
|
|
350
|
+
const rows = planRows(doc);
|
|
351
|
+
const view = buildLlmView(doc);
|
|
352
|
+
assert(view.length < doc.chars, `压缩视图应小于全文(${view.length} < ${doc.chars})`);
|
|
353
|
+
let sent = null;
|
|
354
|
+
const deps = {
|
|
355
|
+
fetch: async (url, init) => {
|
|
356
|
+
sent = { url, body: JSON.parse(init.body) };
|
|
357
|
+
return {
|
|
358
|
+
ok: true,
|
|
359
|
+
status: 200,
|
|
360
|
+
json: async () => ({
|
|
361
|
+
model: 'glm-test',
|
|
362
|
+
choices: [
|
|
363
|
+
{
|
|
364
|
+
message: {
|
|
365
|
+
content: JSON.stringify({ rows: rows.map((r) => ({ index: r.index, summary: `模型摘要-${r.index}` })) }),
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
],
|
|
369
|
+
usage: { total_tokens: 1234 },
|
|
370
|
+
}),
|
|
371
|
+
};
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
const res = await summarizeAll({
|
|
375
|
+
docs: [doc],
|
|
376
|
+
plans: new Map([[doc.rel, rows]]),
|
|
377
|
+
mode: 'llm',
|
|
378
|
+
cost: emptyCost('2026-09-21'),
|
|
379
|
+
date: '2026-09-21',
|
|
380
|
+
llm: { apiKey: 'k', baseUrl: 'https://x/api', model: 'glm-test' },
|
|
381
|
+
deps,
|
|
382
|
+
});
|
|
383
|
+
assertEq(res.calls, 1, '一篇一次调用');
|
|
384
|
+
assertEq(res.tokens, 1234, 'token 记账取 usage');
|
|
385
|
+
for (const r of rows) assertEq(res.byKey.get(r.key).summary, `模型摘要-${r.index}`, '摘要来自模型');
|
|
386
|
+
assert(!sent.body.messages[0].content.includes('第 1 节正文。第 1 节正文。第 1 节正文'), '提示里不应是全文');
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test('LLM 解析:容忍 ```json 围栏与前后废话;解析失败降级规则摘要并记失败', async () => {
|
|
390
|
+
assertEq(parseLlmRows('前言\n```json\n{"rows":[{"index":0,"summary":"S"}]}\n```\n后话')[0].summary, 'S', '围栏解析');
|
|
391
|
+
const doc = mkDoc('docs/s.md', SHORT_DOC);
|
|
392
|
+
const rows = planRows(doc);
|
|
393
|
+
const deps = {
|
|
394
|
+
fetch: async () => ({ ok: true, status: 200, json: async () => ({ choices: [{ message: { content: '这不是 JSON' } }] }) }),
|
|
395
|
+
};
|
|
396
|
+
const res = await summarizeAll({
|
|
397
|
+
docs: [doc],
|
|
398
|
+
plans: new Map([[doc.rel, rows]]),
|
|
399
|
+
mode: 'llm',
|
|
400
|
+
cost: emptyCost('2026-09-21'),
|
|
401
|
+
date: '2026-09-21',
|
|
402
|
+
llm: { apiKey: 'k', baseUrl: 'https://x', model: 'm' },
|
|
403
|
+
deps,
|
|
404
|
+
});
|
|
405
|
+
assertEq(res.failures.length, 1, '记一条失败');
|
|
406
|
+
assertEq(res.byKey.get(rows[0].key).summary, buildRuleSummary(doc, rows[0]), '降级为规则摘要(不报错给主人)');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test('LLM 通路受成本闸:达上限后不再调用(stopped)', async () => {
|
|
410
|
+
const doc = mkDoc('docs/s.md', SHORT_DOC);
|
|
411
|
+
const rows = planRows(doc);
|
|
412
|
+
let calls = 0;
|
|
413
|
+
const res = await summarizeAll({
|
|
414
|
+
docs: [doc, doc],
|
|
415
|
+
plans: new Map([[doc.rel, rows]]),
|
|
416
|
+
mode: 'llm',
|
|
417
|
+
cost: { date: '2026-09-21', calls: LLM_LIMITS.callsPerDay, tokens: 0 },
|
|
418
|
+
date: '2026-09-21',
|
|
419
|
+
llm: { apiKey: 'k', baseUrl: 'https://x', model: 'm' },
|
|
420
|
+
deps: { fetch: async () => { calls += 1; throw new Error('不该被调用'); } },
|
|
421
|
+
});
|
|
422
|
+
assertEq(calls, 0, '闸门内 0 次调用');
|
|
423
|
+
assert(Boolean(res.stopped), 'stopped 有原因');
|
|
424
|
+
assertEq(res.calls, 0, '记账 0');
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test('D2 从严读:输入含家人标记 ⇒ 不送第三方,改用规则摘要并记录', async () => {
|
|
428
|
+
const doc = mkDoc('docs/x.md', '# 家人档设计\n\n## 一、家人投喂口径\n\n家人相关数据的处置条目。\n');
|
|
429
|
+
const rows = planRows(doc);
|
|
430
|
+
let called = false;
|
|
431
|
+
const res = await summarizeAll({
|
|
432
|
+
docs: [doc],
|
|
433
|
+
plans: new Map([[doc.rel, rows]]),
|
|
434
|
+
mode: 'llm',
|
|
435
|
+
cost: emptyCost('2026-09-21'),
|
|
436
|
+
date: '2026-09-21',
|
|
437
|
+
llm: { apiKey: 'k', baseUrl: 'https://x', model: 'm' },
|
|
438
|
+
deps: { fetch: async () => { called = true; throw new Error('不该被调用'); } },
|
|
439
|
+
});
|
|
440
|
+
assertEq(called, false, '一次都没送出去');
|
|
441
|
+
assertEq(res.skippedForFamily, ['docs/x.md'], '记入 skippedForFamily');
|
|
442
|
+
assert(Boolean(res.byKey.get(rows[0].key)), '仍有规则摘要可用');
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------- 4. 并发保护
|
|
446
|
+
|
|
447
|
+
test('busy 判据:只把"库被锁"当可重试(其它错误立即上抛,不掩盖真 bug)', () => {
|
|
448
|
+
assertEq(isBusyError(new Error('database is locked')), true, 'locked');
|
|
449
|
+
assertEq(isBusyError(new Error('SQLITE_BUSY: database table is locked')), true, 'SQLITE_BUSY');
|
|
450
|
+
assertEq(isBusyError(new Error('no such column: foo')), false, '真错误不重试');
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test('withBusyRetry:busy 重试到成功;非 busy 立即返回失败;超次数返回失败不抛', async () => {
|
|
454
|
+
let n = 0;
|
|
455
|
+
const ok = await withBusyRetry(
|
|
456
|
+
() => {
|
|
457
|
+
n += 1;
|
|
458
|
+
if (n < 3) throw new Error('database is locked');
|
|
459
|
+
return 'done';
|
|
460
|
+
},
|
|
461
|
+
{ sleep: async () => {}, delaysMs: [1, 1, 1] },
|
|
462
|
+
);
|
|
463
|
+
assertEq(ok.ok, true, '重试后成功');
|
|
464
|
+
assertEq(ok.value, 'done', '返回值');
|
|
465
|
+
assertEq(ok.attempts, 3, '尝试次数');
|
|
466
|
+
|
|
467
|
+
const bad = await withBusyRetry(() => { throw new Error('no such table'); }, { sleep: async () => {} });
|
|
468
|
+
assertEq(bad.ok, false, '非 busy 失败');
|
|
469
|
+
assertEq(bad.attempts, 1, '不重试');
|
|
470
|
+
|
|
471
|
+
const exhausted = await withBusyRetry(() => { throw new Error('database is locked'); }, { sleep: async () => {}, delaysMs: [1, 1, 1] });
|
|
472
|
+
assertEq(exhausted.ok, false, '超限失败');
|
|
473
|
+
assertEq(exhausted.attempts, 4, '1 次 + 3 次重试');
|
|
474
|
+
assert(exhausted.error.includes('locked'), '错误原文带上');
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('真锁库实测:另一个连接持 EXCLUSIVE ⇒ 本轮记 failed(不抛错、不写坏、旧条保留)', async () => {
|
|
478
|
+
const s = makeSandbox();
|
|
479
|
+
let lock = null;
|
|
480
|
+
try {
|
|
481
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
482
|
+
// 首轮正常落库
|
|
483
|
+
const r1 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
484
|
+
assertEq(r1.status, 'ok', '首轮 ok');
|
|
485
|
+
const before = countAll(s.dbPath);
|
|
486
|
+
// 改文档**首段**(摘要真的变 ⇒ 本轮确实要写库)+ 锁库
|
|
487
|
+
s.touch('docs/a.md', SHORT_DOC.replace('引言:这是一份短文档', '引言:这是一份被改写过的短文档'));
|
|
488
|
+
lock = new DatabaseSync(s.dbPath);
|
|
489
|
+
lock.exec('BEGIN EXCLUSIVE');
|
|
490
|
+
const r2 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
491
|
+
assertEq(r2.status, 'failed', '锁住 ⇒ failed');
|
|
492
|
+
assert(r2.stats.failedCount >= 1, '记了失败');
|
|
493
|
+
assert(r2.stats.busyWaits >= 1, '确实触发了退避重试');
|
|
494
|
+
assertEq(r2.stats.upserted, 0, '一条都没写进去');
|
|
495
|
+
assert(r2.stats.failures[0].error.includes('locked'), `失败原文入 state:${r2.stats.failures[0].error}`);
|
|
496
|
+
assertEq(countAll(s.dbPath), before, '旧条保留(不写坏)');
|
|
497
|
+
} finally {
|
|
498
|
+
try {
|
|
499
|
+
if (lock) {
|
|
500
|
+
lock.exec('ROLLBACK');
|
|
501
|
+
lock.close();
|
|
502
|
+
}
|
|
503
|
+
} catch {
|
|
504
|
+
/* ignore */
|
|
505
|
+
}
|
|
506
|
+
s.cleanup();
|
|
507
|
+
}
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
test('锁释放后自愈:下一轮能把上一轮失败的篇补上(failCount 归零)', async () => {
|
|
511
|
+
const s = makeSandbox();
|
|
512
|
+
try {
|
|
513
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
514
|
+
await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST }); // 先建库与目录
|
|
515
|
+
s.touch('docs/a.md', SHORT_DOC.replace('引言:这是一份短文档', '引言:改写后待补'));
|
|
516
|
+
const lock = new DatabaseSync(s.dbPath);
|
|
517
|
+
lock.exec('BEGIN EXCLUSIVE');
|
|
518
|
+
const r1 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
519
|
+
assertEq(r1.status, 'failed', '锁住时 failed');
|
|
520
|
+
assertEq(r1.state.paths['docs/a.md'].failCount, 1, 'failCount 1');
|
|
521
|
+
lock.exec('ROLLBACK');
|
|
522
|
+
lock.close();
|
|
523
|
+
const r2 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
524
|
+
assertEq(r2.status, 'ok', '解锁后 ok');
|
|
525
|
+
assertEq(r2.stats.rebuilt, 1, '补上了这一篇');
|
|
526
|
+
assertEq(r2.state.paths['docs/a.md'].failCount, 0, 'failCount 归零');
|
|
527
|
+
} finally {
|
|
528
|
+
s.cleanup();
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
// ---------------------------------------------------------------- 5. 幂等 / 变更感知 / 孤条
|
|
533
|
+
|
|
534
|
+
test('幂等:同一批连跑两次 ⇒ 条数不变、第二次全部跳过、无孤条', async () => {
|
|
535
|
+
const s = makeSandbox();
|
|
536
|
+
try {
|
|
537
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
538
|
+
s.write('docs/l.md', longDoc(6, 4000));
|
|
539
|
+
const flags = s.flags({ only: 'docs/a.md,docs/l.md' });
|
|
540
|
+
const r1 = await runDocIndexOnce({ flags, deps: FAST });
|
|
541
|
+
assertEq(r1.status, 'ok', '首轮 ok');
|
|
542
|
+
assert(r1.stats.upserted > 0, '首轮有写入');
|
|
543
|
+
const n1 = countAll(s.dbPath);
|
|
544
|
+
const r2 = await runDocIndexOnce({ flags, deps: FAST });
|
|
545
|
+
assertEq(r2.status, 'noop', '次轮 noop');
|
|
546
|
+
assertEq(r2.stats.skipped, 2, '两篇都跳过');
|
|
547
|
+
assertEq(r2.stats.rebuilt, 0, '重建 0');
|
|
548
|
+
assertEq(r2.stats.upserted, 0, '写库 0(未变条目未重建)');
|
|
549
|
+
assertEq(countAll(s.dbPath), n1, '条数不变');
|
|
550
|
+
const paths = listRows(openRO(s.dbPath).db).map((r) => r.path);
|
|
551
|
+
assertEq(paths.filter((p) => p === 'docs/a.md').length, 1, '短文档主条 1 条');
|
|
552
|
+
assert(paths.filter((p) => p.startsWith('docs/l.md#§')).length >= 2, '长文档按 §N 分条(无主条)');
|
|
553
|
+
assertEq(new Set(paths).size, paths.length, '无重复 path(无孤条)');
|
|
554
|
+
} finally {
|
|
555
|
+
s.cleanup();
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
test('稳定性:连跑三轮都不振荡(跳过轮记 skipped 也算"已同步",次轮不会再重算)', async () => {
|
|
560
|
+
const s = makeSandbox();
|
|
561
|
+
try {
|
|
562
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
563
|
+
s.write('docs/l.md', longDoc(6, 4000));
|
|
564
|
+
const flags = s.flags({ only: 'docs/a.md,docs/l.md' });
|
|
565
|
+
const r1 = await runDocIndexOnce({ flags, deps: FAST });
|
|
566
|
+
const r2 = await runDocIndexOnce({ flags, deps: FAST });
|
|
567
|
+
const r3 = await runDocIndexOnce({ flags, deps: FAST });
|
|
568
|
+
assertEq([r1.status, r2.status, r3.status], ['ok', 'noop', 'noop'], '状态序列');
|
|
569
|
+
assertEq([r1.stats.rebuilt, r2.stats.rebuilt, r3.stats.rebuilt], [2, 0, 0], '重建次数');
|
|
570
|
+
// 关键回归点:跳过轮的 lastResult 必须是 skipped(不是 failed/未同步),否则下一轮又重算
|
|
571
|
+
assertEq(r2.state.paths['docs/l.md'].lastResult, 'skipped', '长文档跳过轮记 skipped');
|
|
572
|
+
assertEq(r3.stats.decisions['docs/l.md'].reason, 'unchanged', '第三轮判据 = unchanged(无振荡)');
|
|
573
|
+
assertEq(r3.stats.upserted, 0, '零写库');
|
|
574
|
+
} finally {
|
|
575
|
+
s.cleanup();
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('日报行规模:跳过轮也显示库内真实规模(回归:曾显示"0 篇 / 0 条")', async () => {
|
|
580
|
+
const s = makeSandbox();
|
|
581
|
+
try {
|
|
582
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
583
|
+
s.write('docs/l.md', longDoc(6, 4000));
|
|
584
|
+
const flags = s.flags({ only: 'docs/a.md,docs/l.md' });
|
|
585
|
+
await runDocIndexOnce({ flags, deps: FAST });
|
|
586
|
+
const total = countAll(s.dbPath);
|
|
587
|
+
const r2 = await runDocIndexOnce({ flags, deps: FAST });
|
|
588
|
+
assertEq(r2.stats.rebuilt, 0, '本轮没重建');
|
|
589
|
+
assertEq(r2.stats.totalRows, total, '日报行规模 = 库内真实条数');
|
|
590
|
+
assertEq(r2.stats.totalDocs, 2, '篇数 = 库内去重篇数');
|
|
591
|
+
assert(r2.state.lastDailyLine.includes(`${total} 条`), `日报行含真实条数:${r2.state.lastDailyLine}`);
|
|
592
|
+
assert(!r2.state.lastDailyLine.includes('0 篇 / 0 条'), '不再显示 0 篇/0 条');
|
|
593
|
+
} finally {
|
|
594
|
+
s.cleanup();
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
test('变更感知:只动改过的那一篇(其它篇连 updated_at 都逐字不变)', async () => {
|
|
599
|
+
const s = makeSandbox();
|
|
600
|
+
try {
|
|
601
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
602
|
+
s.write('docs/b.md', SHORT_DOC.replace('短文档 A', '短文档 B'));
|
|
603
|
+
const r1 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md,docs/b.md' }), deps: FAST });
|
|
604
|
+
assertEq(r1.stats.rebuilt, 2, '首轮两篇');
|
|
605
|
+
const snapshot = snapshotRows(s.dbPath);
|
|
606
|
+
|
|
607
|
+
// 只改 a.md 的**首段**(内容 + mtime + size 都变 ⇒ 摘要跟着变)
|
|
608
|
+
s.touch('docs/a.md', SHORT_DOC.replace('引言:这是一份短文档', '引言:这份短文档已被改写'));
|
|
609
|
+
const r2 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md,docs/b.md' }), deps: FAST });
|
|
610
|
+
assertEq(r2.stats.rebuilt, 1, '只重建 1 篇');
|
|
611
|
+
assertEq(r2.stats.skipped, 1, '跳过 1 篇');
|
|
612
|
+
assertEq(r2.stats.decisions['docs/a.md'].reason, 'mtime-or-size-changed', '理由 = mtime/size 变');
|
|
613
|
+
assertEq(r2.stats.decisions['docs/b.md'].reason, 'unchanged', 'b 未变');
|
|
614
|
+
|
|
615
|
+
const after = snapshotRows(s.dbPath);
|
|
616
|
+
assertEq(after['docs/b.md'], snapshot['docs/b.md'], 'b 条逐字未动(含 updated_at / text_hash)');
|
|
617
|
+
assert(after['docs/a.md'].text_hash !== snapshot['docs/a.md'].text_hash, 'a 条已更新');
|
|
618
|
+
assert(after['docs/a.md'].mtime > snapshot['docs/a.md'].mtime, 'a 条 mtime 跟随文档前进');
|
|
619
|
+
} finally {
|
|
620
|
+
s.cleanup();
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
test('变更感知(边界):只动文末 ⇒ 摘要不变 ⇒ 库里整行不动(但 state 记下新 mtime,不来回重建)', async () => {
|
|
625
|
+
const s = makeSandbox();
|
|
626
|
+
try {
|
|
627
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
628
|
+
const flags = s.flags({ only: 'docs/a.md' });
|
|
629
|
+
await runDocIndexOnce({ flags, deps: FAST });
|
|
630
|
+
const before = snapshotRows(s.dbPath)['docs/a.md'];
|
|
631
|
+
s.touch('docs/a.md', `${SHORT_DOC}\n文末追加一句(不影响摘要)。\n`);
|
|
632
|
+
const r = await runDocIndexOnce({ flags, deps: FAST });
|
|
633
|
+
assertEq(r.stats.rebuilt, 1, 'doc 变了 ⇒ 重算摘要');
|
|
634
|
+
assertEq(r.stats.unchanged, 1, '摘要同哈希 ⇒ 不清写');
|
|
635
|
+
assertEq(r.stats.upserted, 0, '写库 0');
|
|
636
|
+
assertEq(snapshotRows(s.dbPath)['docs/a.md'], before, '库里整行逐字未变');
|
|
637
|
+
assert(r.state.paths['docs/a.md'].mtime > before.mtime, 'state 记下新 mtime');
|
|
638
|
+
// 下一轮:state 已对齐 ⇒ 直接跳过(不会每次都重算摘要)
|
|
639
|
+
const r2 = await runDocIndexOnce({ flags, deps: FAST });
|
|
640
|
+
assertEq(r2.stats.skipped, 1, '次轮跳过');
|
|
641
|
+
assertEq(r2.stats.rebuilt, 0, '不再重算');
|
|
642
|
+
} finally {
|
|
643
|
+
s.cleanup();
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
test('清孤条:文档由长变短 ⇒ 多余的 path#§N 被删除(不留孤条)', async () => {
|
|
648
|
+
const s = makeSandbox();
|
|
649
|
+
try {
|
|
650
|
+
const text = longDoc(8, 3000);
|
|
651
|
+
s.write('docs/l.md', text);
|
|
652
|
+
const r1 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/l.md' }), deps: FAST });
|
|
653
|
+
const rows1 = listRows(openRO(s.dbPath).db).map((r) => r.path);
|
|
654
|
+
assert(rows1.length >= 2, `首轮多条(实际 ${rows1.length})`);
|
|
655
|
+
|
|
656
|
+
// 缩短到 1 条(去掉大部分小节)
|
|
657
|
+
s.touch('docs/l.md', `# 长文档 L\n\n引言。\n\n## 唯一小节\n\n${'短正文。'.repeat(50)}\n`);
|
|
658
|
+
const r2 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/l.md' }), deps: FAST });
|
|
659
|
+
assertEq(r2.status, 'ok', 'ok');
|
|
660
|
+
const rows2 = listRows(openRO(s.dbPath).db).map((r) => r.path);
|
|
661
|
+
assertEq(rows2, ['docs/l.md'], '只剩主条');
|
|
662
|
+
assertEq(r2.stats.staleDeleted, rows1.filter((p) => p !== 'docs/l.md').length, '清掉的孤条数');
|
|
663
|
+
} finally {
|
|
664
|
+
s.cleanup();
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
test('库缺条自愈:手工删掉某篇的条 ⇒ 下一轮按 missing-in-db 重建', async () => {
|
|
669
|
+
const s = makeSandbox();
|
|
670
|
+
try {
|
|
671
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
672
|
+
await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
673
|
+
const db = openRO(s.dbPath).db;
|
|
674
|
+
deleteDoc(db, 'docs/a.md');
|
|
675
|
+
db.close();
|
|
676
|
+
assertEq(countAll(s.dbPath), 0, '已清空');
|
|
677
|
+
const r = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
678
|
+
assertEq(r.stats.decisions['docs/a.md'].reason, 'missing-in-db', '判据');
|
|
679
|
+
assertEq(countAll(s.dbPath), 1, '补回');
|
|
680
|
+
} finally {
|
|
681
|
+
s.cleanup();
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test('diff 判据:force 忽略差分;lastResult 非 ok 也重建', () => {
|
|
686
|
+
const doc = { rel: 'docs/a.md', mtime: 5, size: 5 };
|
|
687
|
+
assertEq(needsRebuild({ doc, prevEntry: null, hasRow: false }).reason, 'new', '无 state');
|
|
688
|
+
assertEq(needsRebuild({ doc, prevEntry: { mtime: 5, size: 5, lastResult: 'ok' }, hasRow: true }).rebuild, false, '一致 ⇒ 跳过');
|
|
689
|
+
assertEq(needsRebuild({ doc, prevEntry: { mtime: 5, size: 5, lastResult: 'ok' }, hasRow: true, force: true }).reason, 'forced', 'force');
|
|
690
|
+
assertEq(needsRebuild({ doc, prevEntry: { mtime: 4, size: 5, lastResult: 'ok' }, hasRow: true }).reason, 'mtime-or-size-changed', 'mtime 变');
|
|
691
|
+
assertEq(needsRebuild({ doc, prevEntry: { mtime: 5, size: 4, lastResult: 'ok' }, hasRow: true }).reason, 'mtime-or-size-changed', 'size 变');
|
|
692
|
+
assertEq(needsRebuild({ doc, prevEntry: { mtime: 5, size: 5, lastResult: 'failed' }, hasRow: true }).reason, 'last-failed', '上轮失败');
|
|
693
|
+
assertEq(needsRebuild({ doc, prevEntry: { mtime: 5, size: 5, lastResult: 'ok' }, hasRow: false }).reason, 'missing-in-db', '库里没条');
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test('摘要口径变化 ⇒ text_hash 变 ⇒ 即使 mtime/size 不变也会更新("摘要变了也要更新")', () => {
|
|
697
|
+
const s = makeSandbox();
|
|
698
|
+
try {
|
|
699
|
+
const store = openDocIndexStore(s.dbPath);
|
|
700
|
+
upsertDocs(store.db, [{ path: 'docs/a.md', mtime: 9, size: 9, textHash: 'old', summary: '旧摘要', updatedAt: 9 }]);
|
|
701
|
+
const r = upsertDocs(store.db, [{ path: 'docs/a.md', mtime: 9, size: 9, textHash: 'new', summary: '新摘要', updatedAt: 9 }]);
|
|
702
|
+
assertEq(r.updated, 1, '摘要变 ⇒ 更新');
|
|
703
|
+
assertEq(getRow(store.db, 'docs/a.md').summary, '新摘要', '内容已换');
|
|
704
|
+
store.db.close();
|
|
705
|
+
} finally {
|
|
706
|
+
s.cleanup();
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// ---------------------------------------------------------------- 6. 范围 / 首期清单 / 逃生舱
|
|
711
|
+
|
|
712
|
+
test('范围:默认首期 10 篇(判据 = 文档总索引「最常用的 15 份」前 10);--all / --only 可覆盖', () => {
|
|
713
|
+
assertEq(FIRST_BATCH.length, 10, '首期 10 篇');
|
|
714
|
+
assertEq(new Set(FIRST_BATCH).size, 10, '无重复');
|
|
715
|
+
for (const p of FIRST_BATCH) assert(p.startsWith('docs/'), `相对 DSH 根口径:${p}`);
|
|
716
|
+
const scanned = [
|
|
717
|
+
{ rel: 'docs/其它.md', abs: 'x', mtime: 1, size: 1 },
|
|
718
|
+
{ rel: 'docs/主人使用手册.md', abs: 'y', mtime: 1, size: 1 },
|
|
719
|
+
];
|
|
720
|
+
const def = resolveScope({ scanned });
|
|
721
|
+
assertEq(def.mode, 'first-batch', '默认模式');
|
|
722
|
+
assertEq(def.docs.map((d) => d.rel), ['docs/主人使用手册.md'], '只取清单里存在的');
|
|
723
|
+
assertEq(def.missing.length, 9, '缺的 9 篇如实报告');
|
|
724
|
+
assertEq(resolveScope({ scanned, flags: { all: true } }).docs.length, 2, '--all');
|
|
725
|
+
assertEq(resolveScope({ scanned, flags: { only: 'docs/其它.md' } }).docs.map((d) => d.rel), ['docs/其它.md'], '--only');
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
test('重建一致(逃生舱):--rebuild 后条数与 path 集合与首跑一致、摘要逐字节相同', async () => {
|
|
729
|
+
const s = makeSandbox();
|
|
730
|
+
try {
|
|
731
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
732
|
+
s.write('docs/l.md', longDoc(6, 4000));
|
|
733
|
+
const flags = s.flags({ only: 'docs/a.md,docs/l.md' });
|
|
734
|
+
await runDocIndexOnce({ flags, deps: FAST });
|
|
735
|
+
const before = snapshotRows(s.dbPath);
|
|
736
|
+
const r = await runDocIndexOnce({ flags: { ...flags, rebuild: true }, deps: FAST });
|
|
737
|
+
assertEq(r.stats.rebuilt, 2, '强制重建 2 篇');
|
|
738
|
+
const after = snapshotRows(s.dbPath);
|
|
739
|
+
assertEq(Object.keys(after).sort(), Object.keys(before).sort(), 'path 集合一致');
|
|
740
|
+
for (const k of Object.keys(before)) {
|
|
741
|
+
assertEq(after[k].text_hash, before[k].text_hash, `${k} 摘要哈希一致(确定性)`);
|
|
742
|
+
assertEq(after[k].summary, before[k].summary, `${k} 摘要逐字节一致`);
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
s.cleanup();
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
test('回滚口径:deleteDoc 清掉主条 + 全部分条(path 与 path#§N)', () => {
|
|
750
|
+
const s = makeSandbox();
|
|
751
|
+
try {
|
|
752
|
+
const store = openDocIndexStore(s.dbPath);
|
|
753
|
+
upsertDocs(store.db, [
|
|
754
|
+
{ path: 'docs/l.md', mtime: 1, size: 1, textHash: 'h1', summary: 'A', updatedAt: 1 },
|
|
755
|
+
{ path: 'docs/l.md#§1', mtime: 1, size: 1, textHash: 'h2', summary: 'B', updatedAt: 1 },
|
|
756
|
+
{ path: 'docs/l.md#§2', mtime: 1, size: 1, textHash: 'h3', summary: 'C', updatedAt: 1 },
|
|
757
|
+
{ path: 'docs/other.md', mtime: 1, size: 1, textHash: 'h4', summary: 'D', updatedAt: 1 },
|
|
758
|
+
]);
|
|
759
|
+
const n = deleteDoc(store.db, 'docs/l.md');
|
|
760
|
+
assertEq(n, 3, '清 3 条(主条 + 2 分条)');
|
|
761
|
+
assertEq(countRows(store.db), 1, '别的篇不受影响');
|
|
762
|
+
assertEq(escapeLike('docs/a_b%c.md'), 'docs/a\\_b\\%c.md', 'LIKE 元字符转义');
|
|
763
|
+
store.db.close();
|
|
764
|
+
} finally {
|
|
765
|
+
s.cleanup();
|
|
766
|
+
}
|
|
767
|
+
});
|
|
768
|
+
|
|
769
|
+
// ---------------------------------------------------------------- 7. 状态文件 / 日报 / dry-run
|
|
770
|
+
|
|
771
|
+
test('state:per-path 记 mtime/size/lastResult/failCount;读文件失败 ⇒ failed 且 failCount 递增', async () => {
|
|
772
|
+
const s = makeSandbox();
|
|
773
|
+
try {
|
|
774
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
775
|
+
s.write('docs/bad.md', '# B\n\n## C\n\n正文\n');
|
|
776
|
+
const deps = {
|
|
777
|
+
...FAST,
|
|
778
|
+
readDoc: (abs) => {
|
|
779
|
+
if (abs.endsWith('bad.md')) throw new Error('模拟读取失败(权限/占用)');
|
|
780
|
+
const text = fs.readFileSync(abs, 'utf8');
|
|
781
|
+
return { text, chars: text.length };
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
const r1 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md,docs/bad.md' }), deps });
|
|
785
|
+
assertEq(r1.status, 'failed', '有失败 ⇒ failed');
|
|
786
|
+
assertEq(r1.state.paths['docs/a.md'].lastResult, 'ok', 'a ok');
|
|
787
|
+
assertEq(r1.state.paths['docs/a.md'].failCount, 0, 'a 无失败');
|
|
788
|
+
assertEq(r1.state.paths['docs/bad.md'].lastResult, 'failed', 'bad failed');
|
|
789
|
+
assertEq(r1.state.paths['docs/bad.md'].failCount, 1, 'failCount 1');
|
|
790
|
+
assert(r1.state.paths['docs/bad.md'].error.includes('读取/解析失败'), '错误原文入 state');
|
|
791
|
+
const r2 = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md,docs/bad.md' }), deps });
|
|
792
|
+
assertEq(r2.state.paths['docs/bad.md'].failCount, 2, 'failCount 递增');
|
|
793
|
+
assertEq(r2.state.paths['docs/bad.md'].mtime > 0, true, 'mtime 仍记录(便于下轮对比)');
|
|
794
|
+
} finally {
|
|
795
|
+
s.cleanup();
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
test('state:schemaVersion / 历史 / cost 段齐备,且原子写(同目录 tmp → rename)', async () => {
|
|
800
|
+
const s = makeSandbox();
|
|
801
|
+
try {
|
|
802
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
803
|
+
const r = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md' }), deps: FAST });
|
|
804
|
+
const st = JSON.parse(fs.readFileSync(s.stateFile, 'utf8'));
|
|
805
|
+
assertEq(st.schemaVersion, 1, 'schemaVersion');
|
|
806
|
+
assertEq(st.runs, 1, 'runs');
|
|
807
|
+
assert(Array.isArray(st.history) && st.history.length === 1, 'history');
|
|
808
|
+
assert(st.cost && typeof st.cost.calls === 'number', 'cost 段');
|
|
809
|
+
assertEq(st.lastDailyLine, r.state.lastDailyLine, '日报行落在 state 里');
|
|
810
|
+
assertEq(fs.readdirSync(path.dirname(s.stateFile)).filter((f) => f.includes('.tmp-')).length, 0, '不留 tmp 残件');
|
|
811
|
+
} finally {
|
|
812
|
+
s.cleanup();
|
|
813
|
+
}
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
test('日报一行:段幂等(写两次内容相同、不重复追加);日报不存在时不新建', () => {
|
|
817
|
+
const s = makeSandbox();
|
|
818
|
+
try {
|
|
819
|
+
const dir = path.join(s.docsRoot, '管家报告');
|
|
820
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
821
|
+
const file = path.join(dir, '2026-09-21-TESTHOST.md');
|
|
822
|
+
fs.writeFileSync(file, '# 大管家日报 · 2026-09-21\n\n## 🐳 队列推进提醒\n\n> x\n', 'utf8');
|
|
823
|
+
const line = '📄 文档索引(doc-index):10 篇 / 18 条 · 失败 0';
|
|
824
|
+
const r1 = updateDailySection({ file, line });
|
|
825
|
+
assertEq(r1.created, true, '首次新建段');
|
|
826
|
+
const after1 = fs.readFileSync(file, 'utf8');
|
|
827
|
+
assert(after1.includes(DAILY_SECTION_TITLE), '段标题在');
|
|
828
|
+
assert(after1.includes('> ' + line), '行在');
|
|
829
|
+
const r2 = updateDailySection({ file, line });
|
|
830
|
+
assertEq(r2.created, false, '第二次不新建');
|
|
831
|
+
assertEq(r2.reason, 'unchanged', '内容相同 ⇒ unchanged');
|
|
832
|
+
assertEq(fs.readFileSync(file, 'utf8'), after1, '逐字节不变(幂等)');
|
|
833
|
+
assertEq(after1.split(DAILY_SECTION_TITLE).length - 1, 1, '段只出现一次');
|
|
834
|
+
// 更新内容(模拟下一轮)
|
|
835
|
+
updateDailySection({ file, line: '📄 文档索引(doc-index):10 篇 / 18 条 · 失败 2' });
|
|
836
|
+
const after3 = fs.readFileSync(file, 'utf8');
|
|
837
|
+
assert(after3.includes('失败 2'), '内容已更新');
|
|
838
|
+
assertEq(after3.split(DAILY_SECTION_TITLE).length - 1, 1, '仍只有一段');
|
|
839
|
+
assert(after3.includes('## 🐳 队列推进提醒'), '原有段保留');
|
|
840
|
+
// 文件不存在 ⇒ 不新建
|
|
841
|
+
const missing = path.join(dir, '2026-09-22-TESTHOST.md');
|
|
842
|
+
assertEq(updateDailySection({ file: missing, line }).reason, 'daily-missing', '不新建');
|
|
843
|
+
assertEq(fs.existsSync(missing), false, '确认没建文件');
|
|
844
|
+
} finally {
|
|
845
|
+
s.cleanup();
|
|
846
|
+
}
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
test('日报路径口径:<docs>\\管家报告\\<YYYY-MM-DD>-<host>.md(与 report.mjs 同)', () => {
|
|
850
|
+
const drv = path.parse(process.cwd()).root;
|
|
851
|
+
const docsDir = path.join(drv, 'DSH', 'docs');
|
|
852
|
+
const p = dailyReportPath({ docsDir, host: 'H', now: Date.UTC(2026, 8, 21, 4, 0, 0) });
|
|
853
|
+
assertEq(path.basename(p), '2026-09-21-H.md', '文件名');
|
|
854
|
+
assertEq(path.dirname(p), path.join(docsDir, '管家报告'), '目录');
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
test('renderDailyLine:把本轮统计/成本/失败渲染成一行(失败带 ⚠️ 摘要)', () => {
|
|
858
|
+
const line = renderDailyLine({
|
|
859
|
+
lastRun: { totalDocs: 10, totalRows: 18, rebuilt: 3, skipped: 7, upserted: 4, staleDeleted: 1, failed: [{ error: 'boom' }] },
|
|
860
|
+
cost: { calls: 2, tokens: 3456 },
|
|
861
|
+
});
|
|
862
|
+
assert(line.includes('10 篇 / 18 条'), '规模');
|
|
863
|
+
assert(line.includes('重建 3'), '重建');
|
|
864
|
+
assert(line.includes('2 次调用 3456 token'), '成本');
|
|
865
|
+
assert(line.includes('⚠️'), '失败标记');
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test('dry-run:不建库、不改 state、不写日报(只算不写)', async () => {
|
|
869
|
+
const s = makeSandbox();
|
|
870
|
+
try {
|
|
871
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
872
|
+
const dir = path.join(s.docsRoot, '管家报告');
|
|
873
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
874
|
+
const daily = path.join(dir, '2026-09-21-TESTHOST.md');
|
|
875
|
+
fs.writeFileSync(daily, '# 日报\n', 'utf8');
|
|
876
|
+
const r = await runDocIndexOnce({ flags: s.flags({ only: 'docs/a.md', 'dry-run': true, 'no-daily': false }), deps: FAST });
|
|
877
|
+
assertEq(fs.existsSync(s.dbPath), false, '不建库');
|
|
878
|
+
assertEq(fs.existsSync(s.stateFile), false, '不写 state');
|
|
879
|
+
assertEq(fs.readFileSync(daily, 'utf8'), '# 日报\n', '不写日报');
|
|
880
|
+
assertEq(r.stats.totalRows > 0, true, '仍算了条数');
|
|
881
|
+
} finally {
|
|
882
|
+
s.cleanup();
|
|
883
|
+
}
|
|
884
|
+
});
|
|
885
|
+
|
|
886
|
+
test('多人口径:--owner-user / --visibility 可显式覆盖(家人档预留)', async () => {
|
|
887
|
+
const s = makeSandbox();
|
|
888
|
+
try {
|
|
889
|
+
s.write('docs/a.md', SHORT_DOC);
|
|
890
|
+
await runDocIndexOnce({
|
|
891
|
+
flags: s.flags({ only: 'docs/a.md', 'owner-user': 'someone', visibility: 'family' }),
|
|
892
|
+
deps: FAST,
|
|
893
|
+
});
|
|
894
|
+
const row = getRow(openRO(s.dbPath).db, 'docs/a.md');
|
|
895
|
+
assertEq(row.owner_user, 'someone', 'owner_user');
|
|
896
|
+
assertEq(row.visibility, 'family', 'visibility');
|
|
897
|
+
} finally {
|
|
898
|
+
s.cleanup();
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test('分类标记:doc_index 与 memories 无关(不写主记忆库的机制保证)', () => {
|
|
903
|
+
// 说明性断言:本模块只 import doc-index-store(独立库),不 import memory-source / memory.db
|
|
904
|
+
const src = fs.readFileSync(new URL('../lib/doc-index.mjs', import.meta.url), 'utf8');
|
|
905
|
+
assert(!src.includes('memory/memory.db'), '不引用主记忆库路径');
|
|
906
|
+
assert(!src.includes('memories'), '不出现 memories 表名');
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
// ---------------------------------------------------------------- helpers
|
|
910
|
+
|
|
911
|
+
function countAll(dbPath) {
|
|
912
|
+
const db = openRO(dbPath).db;
|
|
913
|
+
const n = countRows(db);
|
|
914
|
+
db.close();
|
|
915
|
+
return n;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function openRO(dbPath) {
|
|
919
|
+
return { db: new DatabaseSync(dbPath) };
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function snapshotRows(dbPath) {
|
|
923
|
+
const db = openRO(dbPath).db;
|
|
924
|
+
const out = {};
|
|
925
|
+
for (const r of db.prepare('SELECT * FROM doc_index ORDER BY path').all()) out[r.path] = r;
|
|
926
|
+
db.close();
|
|
927
|
+
return out;
|
|
928
|
+
}
|