@modusensus/dsh-mneme 0.10.1 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +10 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +414 -12
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1637 -110
- package/lib/config-keys.js +144 -0
- package/lib/config.js +60 -1
- package/lib/document.js +9 -3
- package/lib/dream/clustering.js +6 -1
- package/lib/index.js +87 -5
- package/lib/inject.js +18 -4
- package/lib/lang.js +82 -0
- package/lib/profile-facts.js +356 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +112 -4
- package/lib/settings.js +13 -1
- package/lib/store.js +978 -27
- package/lib/tools.js +244 -1
- package/lib/user-settings.js +63 -7
- package/lib/wiki/inject.js +164 -0
- package/lib/wiki/partition.js +200 -0
- package/lib/wiki/plan.js +214 -0
- package/lib/wiki/queries.js +360 -0
- package/lib/wiki/render.js +187 -0
- package/lib/wiki/scan.js +371 -0
- package/lib/wiki/t0.js +463 -0
- package/lib/wiki/t1.js +496 -0
- package/lib/write-admission.js +5 -3
- package/package.json +4 -2
- package/scripts/sync-test-badge.mjs +1 -1
- package/scripts/test-count-sync.mjs +9 -5
- package/src/api-standalone.js +17 -21
- package/src/api.js +414 -12
- package/src/bootstrap-http.js +105 -0
- package/src/bootstrap.js +5 -1
- package/src/choro-command.js +104 -0
- package/src/config-keys.js +144 -0
- package/src/config.js +60 -1
- package/src/document.js +9 -3
- package/src/dream/clustering.js +6 -1
- package/src/index.js +87 -5
- package/src/inject.js +18 -4
- package/src/lang.js +82 -0
- package/src/profile-facts.js +356 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +112 -4
- package/src/settings.js +13 -1
- package/src/store.js +978 -27
- package/src/tools.js +244 -1
- package/src/user-settings.js +63 -7
- package/src/wiki/inject.js +164 -0
- package/src/wiki/partition.js +200 -0
- package/src/wiki/plan.js +214 -0
- package/src/wiki/queries.js +360 -0
- package/src/wiki/render.js +187 -0
- package/src/wiki/scan.js +371 -0
- package/src/wiki/t0.js +463 -0
- package/src/wiki/t1.js +496 -0
- package/src/write-admission.js +5 -3
- package/test/api-routes-gaps.test.js +131 -1
- package/test/api.test.js +9 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +802 -21
- package/test/config-keys-routes.test.js +233 -0
- package/test/config.test.js +79 -0
- package/test/contrast.test.js +14 -7
- package/test/document.test.js +32 -0
- package/test/embedding-cache-f32.test.js +276 -0
- package/test/inject-pin-explicit.test.js +300 -0
- package/test/inject-pin-vector-crowd.test.js +225 -0
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- package/test/persona-settings.test.js +31 -0
- package/test/profile-facts.test.js +492 -0
- package/test/quality-backfill.test.js +290 -0
- package/test/quality-filter.test.js +113 -4
- package/test/service.test.js +1 -1
- package/test/session-gate.test.js +1 -1
- package/test/test-count-sync.test.js +14 -3
- package/test/tool-state-ignore.test.js +65 -0
- package/test/tools-dto-schema-matrix.test.js +23 -2
- package/test/tools.test.js +144 -7
- package/test/wiki-api.test.js +273 -0
- package/test/wiki-inject.test.js +265 -0
- package/test/wiki-partition.test.js +112 -0
- package/test/wiki-plan.test.js +80 -0
- package/test/wiki-queries.test.js +270 -0
- package/test/wiki-scan.test.js +208 -0
- package/test/wiki-store.test.js +190 -0
- package/test/wiki-t0.test.js +380 -0
- package/test/wiki-t1.test.js +235 -0
- package/test/wiki-tools.test.js +176 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
// queries.js 的回归锁(M1 读侧)。全部用例走真实 T0(fixture 仓库 apply)建数据,
|
|
2
|
+
// 不手插 DB 行——读侧的契约是「DB 索引 × 磁盘真源」的对账,手插行测不出盘上缺页
|
|
3
|
+
// 这类真实故障。锁的点:slug 白名单解析(查不到返回 null,绝不拿用户输入拼路径)、
|
|
4
|
+
// 盘上缺页如实报 page-missing、检索分节与行号、repo 解析的歧义处理。
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, unlinkSync, readFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { runChoroT0 } from "../src/wiki/t0.js";
|
|
12
|
+
import { createStore } from "../src/store.js";
|
|
13
|
+
import { createChoroQueries } from "../src/wiki/queries.js";
|
|
14
|
+
|
|
15
|
+
const FIXED_NOW = () => "2026-11-05T00:00:00.000Z";
|
|
16
|
+
|
|
17
|
+
function makeFixture() {
|
|
18
|
+
const root = mkdtempSync(join(tmpdir(), "choro-queries-"));
|
|
19
|
+
writeFileSync(join(root, "package.json"), JSON.stringify({ name: "qfix", scripts: { test: "node --test" } }, null, 2) + "\n", "utf8");
|
|
20
|
+
mkdirSync(join(root, "src"), { recursive: true });
|
|
21
|
+
writeFileSync(join(root, "src", "a.js"), 'import { b } from "./b.js";\nimport h from "left-pad";\nexport const a = () => b();\n', "utf8");
|
|
22
|
+
writeFileSync(join(root, "src", "b.js"), 'import { b2 } from "./b2.js";\nexport const b = () => b2();\n', "utf8");
|
|
23
|
+
writeFileSync(join(root, "src", "b2.js"), "export const b2 = () => 1;\n", "utf8");
|
|
24
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
|
25
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
26
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rmTempBestEffort(dir) {
|
|
31
|
+
for (let i = 0; i < 5; i++) {
|
|
32
|
+
try {
|
|
33
|
+
rmSync(dir, { recursive: true, force: true });
|
|
34
|
+
return;
|
|
35
|
+
} catch {
|
|
36
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
console.warn(`[wiki-queries.test] 临时目录清理未完成:${dir}`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function setup() {
|
|
43
|
+
const root = makeFixture();
|
|
44
|
+
const store = createStore(":memory:");
|
|
45
|
+
runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW });
|
|
46
|
+
return { root, store, q: createChoroQueries({ store }) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// stub embedder:文本含 "src" → [1,0],否则 [0,1](固定二维,可断言方向与失配)
|
|
50
|
+
function stubEmbedder(model = "stub-1") {
|
|
51
|
+
return {
|
|
52
|
+
model,
|
|
53
|
+
embedSingle: async (text) => (/src/.test(text) ? [1, 0] : [0, 1]),
|
|
54
|
+
embed: async (text) => (/src/.test(text) ? [1, 0] : [0, 1]),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// --- 收尾档 P37–P39:向量惰性物化与检索 -------------------------------------
|
|
59
|
+
|
|
60
|
+
test("embedPageVectors:首次全嵌,generated_at 没变就跳过,变了重嵌(staleness 两维之一)", async () => {
|
|
61
|
+
const { root, store, q } = setup();
|
|
62
|
+
try {
|
|
63
|
+
const repo = q.resolveRepo(undefined);
|
|
64
|
+
const emb = stubEmbedder();
|
|
65
|
+
const r1 = await q.embedPageVectors(repo.id, emb);
|
|
66
|
+
assert.equal(r1.embedded, 2, "两页全嵌");
|
|
67
|
+
const r2 = await q.embedPageVectors(repo.id, emb);
|
|
68
|
+
assert.equal(r2.embedded, 0);
|
|
69
|
+
assert.equal(r2.skipped, 2, "generated_at 没变 → 全跳过");
|
|
70
|
+
// 页面内容变了(generated_at 推进)→ 只重嵌变的那页
|
|
71
|
+
const pagePath = join(root, ".chorography", "modules", "src.md");
|
|
72
|
+
const before = readFileSync(pagePath, "utf8");
|
|
73
|
+
writeFileSync(pagePath, `${before}<!-- CHORO:HUMAN:START -->\n人工补充\n<!-- CHORO:HUMAN:END -->\n`, "utf8");
|
|
74
|
+
store.db.prepare(
|
|
75
|
+
"UPDATE choro_pages SET generated_at = ? WHERE generated_at IS NOT NULL AND id IN (SELECT p.id FROM choro_pages p JOIN choro_modules m ON p.module_id = m.id WHERE m.slug = 'src')"
|
|
76
|
+
).run("2026-11-06T00:00:00.000Z");
|
|
77
|
+
const r3 = await q.embedPageVectors(repo.id, emb);
|
|
78
|
+
assert.equal(r3.embedded, 1, "只有 src 页重嵌");
|
|
79
|
+
assert.equal(r3.stale, 1, "旧向量按 stale 记账");
|
|
80
|
+
} finally {
|
|
81
|
+
store.close();
|
|
82
|
+
rmTempBestEffort(root);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("dim 失配(换 embedder 模型)→ 惰性全量重嵌(P38:不锁死,代价只是几十页)", async () => {
|
|
87
|
+
const { root, store, q } = setup();
|
|
88
|
+
try {
|
|
89
|
+
const repo = q.resolveRepo(undefined);
|
|
90
|
+
await q.embedPageVectors(repo.id, stubEmbedder("stub-1"));
|
|
91
|
+
// 换模型:维度从 2 → 3。expectDim 来自查询向量(路由先嵌查询再物化)
|
|
92
|
+
const emb3 = {
|
|
93
|
+
model: "stub-2",
|
|
94
|
+
embedSingle: async () => [1, 0, 0],
|
|
95
|
+
embed: async () => [1, 0, 0],
|
|
96
|
+
};
|
|
97
|
+
const r = await q.embedPageVectors(repo.id, emb3, { expectDim: 3 });
|
|
98
|
+
assert.equal(r.embedded, 2, "维度变了 → 全部重嵌(fresh 检查被 dimDrift 压过)");
|
|
99
|
+
assert.equal(r.dimMismatch, true, "失配被记账");
|
|
100
|
+
const rows = store.getChoroPageVectors(repo.id);
|
|
101
|
+
assert.ok(rows.every((row) => row.dim === 3 && row.model === "stub-2"), "旧维度行不复存");
|
|
102
|
+
// 不给 expectDim(无查询场景)→ fresh 行照旧跳过
|
|
103
|
+
const r2 = await q.embedPageVectors(repo.id, emb3);
|
|
104
|
+
assert.equal(r2.embedded, 0);
|
|
105
|
+
assert.equal(r2.skipped, 2);
|
|
106
|
+
} finally {
|
|
107
|
+
store.close();
|
|
108
|
+
rmTempBestEffort(root);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("searchSemantic:cosine 排序 + dim 失配行被过滤 + embedder 缺席返回 null", async () => {
|
|
113
|
+
const { root, store, q } = setup();
|
|
114
|
+
try {
|
|
115
|
+
const repo = q.resolveRepo(undefined);
|
|
116
|
+
await q.embedPageVectors(repo.id, stubEmbedder("stub-1"));
|
|
117
|
+
const hits = await q.searchSemantic(repo.id, stubEmbedder("stub-1"), "src 相关的页");
|
|
118
|
+
assert.ok(Array.isArray(hits) && hits.length >= 1);
|
|
119
|
+
assert.equal(hits[0].slug, "src", "查询向量 [1,0] 与 src 页同向 → 排前");
|
|
120
|
+
// 查询向量三维:与所有行 dim 失配 → 无可比行,返回空数组而不是抛
|
|
121
|
+
const dim3 = { embedSingle: async () => [1, 0, 0], embed: async () => [1, 0, 0] };
|
|
122
|
+
const empty = await q.searchSemantic(repo.id, dim3, "x");
|
|
123
|
+
assert.deepEqual(empty, []);
|
|
124
|
+
assert.equal(await q.searchSemantic(repo.id, null, "x"), null);
|
|
125
|
+
assert.deepEqual(await q.searchSemantic(repo.id, stubEmbedder(), " "), [], "空查询不嵌入、返回空数组");
|
|
126
|
+
} finally {
|
|
127
|
+
store.close();
|
|
128
|
+
rmTempBestEffort(root);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("listRepos / resolveRepo:单仓自动收敛,多仓歧义要显式给键", () => {
|
|
133
|
+
const { root, store, q } = setup();
|
|
134
|
+
try {
|
|
135
|
+
const repos = q.listRepos();
|
|
136
|
+
assert.equal(repos.length, 1);
|
|
137
|
+
assert.equal(repos[0].module_count, 2, "src 与 root 两个模块(1 条跨模块边不触发合并)");
|
|
138
|
+
assert.equal(repos[0].page_count, 2);
|
|
139
|
+
const auto = q.resolveRepo(undefined);
|
|
140
|
+
assert.equal(auto.id, repos[0].id, "只有一个仓库时不带键也能解析");
|
|
141
|
+
assert.ok(q.resolveRepo("qfix").id, "按显示名解析");
|
|
142
|
+
assert.equal(q.resolveRepo("no-such-repo"), null);
|
|
143
|
+
// 造第二个仓库让解析变歧义
|
|
144
|
+
const root2 = makeFixture();
|
|
145
|
+
try {
|
|
146
|
+
runChoroT0({ repoRoot: root2, store, apply: true, now: FIXED_NOW });
|
|
147
|
+
const amb = q.resolveRepo(undefined);
|
|
148
|
+
assert.ok(amb.ambiguous, "多仓无键 → 歧义清单而不是猜一个");
|
|
149
|
+
assert.equal(amb.ambiguous.length, 2);
|
|
150
|
+
} finally {
|
|
151
|
+
rmTempBestEffort(root2);
|
|
152
|
+
}
|
|
153
|
+
} finally {
|
|
154
|
+
store.close();
|
|
155
|
+
rmTempBestEffort(root);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("moduleMap:模块、页面状态与跨模块边同源返回", () => {
|
|
160
|
+
const { root, store, q } = setup();
|
|
161
|
+
try {
|
|
162
|
+
const repo = q.resolveRepo(undefined);
|
|
163
|
+
const { modules, edges } = q.moduleMap(repo.id);
|
|
164
|
+
const slugs = modules.map((m) => m.slug).sort();
|
|
165
|
+
assert.deepEqual(slugs, ["root", "src"]);
|
|
166
|
+
const src = modules.find((m) => m.slug === "src");
|
|
167
|
+
assert.equal(src.page_status, "skeleton");
|
|
168
|
+
// fixture 的引用全在 src 模块内部(a→b→b2),left-pad 是外部依赖,root 只有
|
|
169
|
+
// package.json 没有代码——跨模块边应为空集。跨模块聚erge本身由 wiki-store 的
|
|
170
|
+
// 整仓替换用例与 t0 的 moduleEdges 排序覆盖。
|
|
171
|
+
assert.deepEqual(edges, []);
|
|
172
|
+
} finally {
|
|
173
|
+
store.close();
|
|
174
|
+
rmTempBestEffort(root);
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("readPage:正文来自磁盘真源;盘上缺页如实报 page-missing;未知 slug 返回 null", () => {
|
|
179
|
+
const { root, store, q } = setup();
|
|
180
|
+
try {
|
|
181
|
+
const repo = q.resolveRepo(undefined);
|
|
182
|
+
const page = q.readPage(repo.id, "src");
|
|
183
|
+
assert.equal(page.slug, "src");
|
|
184
|
+
assert.ok(page.content.includes("## 文件清单"), "正文是骨架页");
|
|
185
|
+
assert.equal(page.content.split("\n").length > 10, true);
|
|
186
|
+
assert.equal(q.readPage(repo.id, "../../etc/passwd"), null, "未知 slug 一律 null(路径零用户输入)");
|
|
187
|
+
// 盘上删页:索引还在、读侧如实报缺
|
|
188
|
+
unlinkSync(join(root, ".chorography", "modules", "src.md"));
|
|
189
|
+
const missing = q.readPage(repo.id, "src");
|
|
190
|
+
assert.equal(missing.error, "page-missing");
|
|
191
|
+
} finally {
|
|
192
|
+
store.close();
|
|
193
|
+
rmTempBestEffort(root);
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("search:按节返回命中与行号,空 q 返回空数组,module 过滤生效", () => {
|
|
198
|
+
const { root, store, q } = setup();
|
|
199
|
+
try {
|
|
200
|
+
const repo = q.resolveRepo(undefined);
|
|
201
|
+
// 骨架页里必有「文件清单」节标题与 b2.js 文件名
|
|
202
|
+
const hits = q.search(repo.id, "文件清单");
|
|
203
|
+
assert.ok(hits.length >= 2, "两页都有文件清单节");
|
|
204
|
+
assert.ok(hits.every((h) => h.line >= 1 && typeof h.section === "string"));
|
|
205
|
+
const byFile = q.search(repo.id, "b2.js");
|
|
206
|
+
// 命中两处是契约而非巧合:frontmatter 的 sourceFiles(节=页首)+ 正文「文件
|
|
207
|
+
// 清单」表。分节让调用方能区分这两层。
|
|
208
|
+
assert.equal(byFile.length, 2);
|
|
209
|
+
assert.ok(byFile.every((h) => h.slug === "src"), "b2.js 只属于 src 页");
|
|
210
|
+
assert.equal(byFile.filter((h) => h.section.startsWith("文件清单")).length, 1, "节名带数量后缀(文件清单(N 个)),按前缀匹配");
|
|
211
|
+
assert.deepEqual(q.search(repo.id, " "), [], "空 q 不报错返回空");
|
|
212
|
+
const inRoot = q.search(repo.id, "package.json", { moduleSlug: "root" });
|
|
213
|
+
assert.equal(inRoot.length, 2, "与 b2.js 同款两层命中:frontmatter(页首)+ 文件清单表");
|
|
214
|
+
assert.equal(inRoot[0].slug, "root");
|
|
215
|
+
assert.deepEqual(q.search(repo.id, "package.json", { moduleSlug: "src" }), [], "模块过滤真生效");
|
|
216
|
+
} finally {
|
|
217
|
+
store.close();
|
|
218
|
+
rmTempBestEffort(root);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
test("repoByCwd:祖先前缀命中、最长 root 胜出、cwd 不存在返回 null", () => {
|
|
222
|
+
const { root, store, q } = setup();
|
|
223
|
+
let nested = null;
|
|
224
|
+
try {
|
|
225
|
+
// 精确根目录
|
|
226
|
+
const exact = q.repoByCwd(root);
|
|
227
|
+
assert.ok(exact, "仓库根命中");
|
|
228
|
+
// 子目录也要命中——真实会话的 cwd 常常是 D:/repo/src 这种
|
|
229
|
+
const sub = q.repoByCwd(join(root, "src"));
|
|
230
|
+
assert.equal(sub.id, exact.id, "子目录按祖先前缀命中同一个仓库");
|
|
231
|
+
assert.equal(q.repoByCwd(join(root, "no-such-dir")), null, "cwd 不存在 → null(不是抛错,注入在热路径上)");
|
|
232
|
+
assert.equal(q.repoByCwd(""), null);
|
|
233
|
+
assert.equal(q.repoByCwd(null), null);
|
|
234
|
+
// 嵌套仓库:外层先登记,内层后登记 → 内层会话必须被内层抢走(最长前缀)
|
|
235
|
+
nested = makeFixture();
|
|
236
|
+
runChoroT0({ repoRoot: nested, store, apply: true, now: FIXED_NOW });
|
|
237
|
+
const inner = q.repoByCwd(join(nested, "src"));
|
|
238
|
+
assert.equal(inner.root_path, nested, "嵌套时取最深那层,否则内层会话被外层仓库抢走");
|
|
239
|
+
assert.equal(q.repoByCwd(root).root_path, root, "外层 cwd 仍落外层");
|
|
240
|
+
} finally {
|
|
241
|
+
store.close();
|
|
242
|
+
if (nested) rmTempBestEffort(nested);
|
|
243
|
+
rmTempBestEffort(root);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("outline:模块名 + 文件数 + 页状态 + 页相对路径,不读正文", () => {
|
|
248
|
+
const { root, store, q } = setup();
|
|
249
|
+
try {
|
|
250
|
+
const repo = q.resolveRepo(undefined);
|
|
251
|
+
const out = q.outline(repo.id);
|
|
252
|
+
assert.equal(out.repo.id, repo.id);
|
|
253
|
+
assert.equal(out.repo.name, "qfix");
|
|
254
|
+
assert.equal(out.modules.length, 2);
|
|
255
|
+
const srcMod = out.modules.find((m) => m.slug === "src");
|
|
256
|
+
assert.equal(srcMod.status, "skeleton");
|
|
257
|
+
assert.equal(srcMod.relPath, ".chorography/modules/src.md");
|
|
258
|
+
assert.ok(srcMod.fileCount >= 3);
|
|
259
|
+
assert.equal(srcMod.parentSlug, null);
|
|
260
|
+
assert.ok(out.generatedAt, "生成时间由页行的 generated_at 汇总");
|
|
261
|
+
// 「只注标题级」的机制锁:提纲里不许出现页正文片段(骨架页的节标题/说明行)
|
|
262
|
+
const serialized = JSON.stringify(out);
|
|
263
|
+
assert.equal(serialized.includes("## 文件清单"), false, "提纲不含正文节标题");
|
|
264
|
+
assert.equal(serialized.includes("确定性骨架"), false, "提纲不含页内说明行");
|
|
265
|
+
assert.equal(q.outline(9999), null, "未知 repo → null");
|
|
266
|
+
} finally {
|
|
267
|
+
store.close();
|
|
268
|
+
rmTempBestEffort(root);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// scan.js 的回归锁:文件清单(默认排除 / gitignore 子集 / include 白名单)与
|
|
2
|
+
// import 图(ESM 静态/动态、CJS、Vue SFC、外部包聚合、builtin 过滤、unresolved 记账)。
|
|
3
|
+
// 锁的回归类型:正则提取器在「副作用 import / 再导出 / 多行 import」上漏边;
|
|
4
|
+
// glob 在无斜杠模式上漏掉任意层级语义;解析失败必须记 skipped 而不是中断(spec 降级表)。
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { listFiles, parseImportGraph, compileExclude, compileInclude, readRootPackage, workspacePrefixes } from "../src/wiki/scan.js";
|
|
11
|
+
|
|
12
|
+
function makeRepo(files) {
|
|
13
|
+
const root = mkdtempSync(join(tmpdir(), "choro-scan-"));
|
|
14
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
15
|
+
const abs = join(root, rel);
|
|
16
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
17
|
+
writeFileSync(abs, content, "utf8");
|
|
18
|
+
}
|
|
19
|
+
return root;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("文件清单:默认排除 node_modules/dist 与二进制/锁文件", () => {
|
|
23
|
+
const root = makeRepo({
|
|
24
|
+
"src/a.js": "export const a = 1;\n",
|
|
25
|
+
"node_modules/pkg/index.js": "module.exports = 1;\n",
|
|
26
|
+
"dist/bundle.js": "var a=1;\n",
|
|
27
|
+
"logo.png": "binary-ish",
|
|
28
|
+
"package-lock.json": "{}",
|
|
29
|
+
"README.md": "# x\n",
|
|
30
|
+
});
|
|
31
|
+
try {
|
|
32
|
+
assert.deepEqual(listFiles(root), ["README.md", "src/a.js"]);
|
|
33
|
+
} finally {
|
|
34
|
+
rmSync(root, { recursive: true, force: true });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// 锁的回归类型:默认排除表退化成「逐个点名已知的点目录」。V2 在三仓实测到的会话产物
|
|
39
|
+
// 目录是 .qoder-credits / .superpowers / .shots / .zcode / .dsh-ops / .v2c——点名法每换一个
|
|
40
|
+
// 工具就漏一批(baleen 15 模块里 6 个、study-desk 5 个里 3 个都曾是这样混进来的),
|
|
41
|
+
// 所以规则改成「点开头目录整类」,本用例用一份从未在任何名单里出现过的 .whatever 来锁这条。
|
|
42
|
+
// 反向断言同样重要:点开头的**文件**是仓库配置(.eslintrc.json),必须照旧进清单。
|
|
43
|
+
test("文件清单:点开头目录整类排除(任意深度),点开头文件照旧扫描", () => {
|
|
44
|
+
const root = makeRepo({
|
|
45
|
+
"README.md": "# x\n",
|
|
46
|
+
"src/a.js": "export const a = 1;\n",
|
|
47
|
+
".git/config": "",
|
|
48
|
+
".whatever/session.md": "# 会话产物\n",
|
|
49
|
+
"nested/.qoder-credits/report.json": "{}",
|
|
50
|
+
"src/.shots/notes.md": "# 调试快照\n",
|
|
51
|
+
".eslintrc.json": "{}",
|
|
52
|
+
"src/.prettierrc.json": "{}",
|
|
53
|
+
});
|
|
54
|
+
try {
|
|
55
|
+
assert.deepEqual(listFiles(root), [
|
|
56
|
+
".eslintrc.json",
|
|
57
|
+
"README.md",
|
|
58
|
+
"src/.prettierrc.json",
|
|
59
|
+
"src/a.js",
|
|
60
|
+
]);
|
|
61
|
+
} finally {
|
|
62
|
+
rmSync(root, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("文件清单:.gitignore 子集——无斜杠模式命中任意层级,锚定模式只命中根部", () => {
|
|
67
|
+
const root = makeRepo({
|
|
68
|
+
"keep.js": "",
|
|
69
|
+
"docs/readme.md": "",
|
|
70
|
+
"docs/inner/notes.md": "",
|
|
71
|
+
"build/out.js": "",
|
|
72
|
+
"sub/build/out.js": "",
|
|
73
|
+
"vendor/v.js": "",
|
|
74
|
+
});
|
|
75
|
+
try {
|
|
76
|
+
const m = compileExclude(["*.md", "build", "/vendor"]);
|
|
77
|
+
assert.equal(m.excludedFile("docs/readme.md"), true, "*.md 命中任意层级");
|
|
78
|
+
assert.equal(m.excludedFile("docs/inner/notes.md"), true);
|
|
79
|
+
assert.equal(m.excludedFile("keep.js"), false);
|
|
80
|
+
assert.equal(m.excludedFile("build/out.js"), true, "目录级排除连带文件");
|
|
81
|
+
assert.equal(m.excludedFile("sub/build/out.js"), true, "无斜杠目录名也是任意层级");
|
|
82
|
+
assert.equal(m.excludedFile("vendor/v.js"), true, "前导 / 锚定到根");
|
|
83
|
+
const inc = compileInclude(["docs"]);
|
|
84
|
+
assert.equal(inc.matchesFile("docs/readme.md"), true);
|
|
85
|
+
assert.equal(inc.matchesFile("src/a.js"), false);
|
|
86
|
+
} finally {
|
|
87
|
+
rmSync(root, { recursive: true, force: true });
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("文件清单:include 白名单非空时只留命中的文件", () => {
|
|
92
|
+
const root = makeRepo({ "src/a.js": "", "docs/b.md": "", "c.js": "" });
|
|
93
|
+
try {
|
|
94
|
+
const files = listFiles(root, { exclude: [] });
|
|
95
|
+
const inc = compileInclude(["src/**"]);
|
|
96
|
+
const kept = files.filter((f) => inc.matchesFile(f));
|
|
97
|
+
assert.deepEqual(kept, ["src/a.js"]);
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(root, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("import 图:ESM 静态/副作用/动态/再导出、CJS、多行 import 全部成边", () => {
|
|
104
|
+
const root = makeRepo({
|
|
105
|
+
"src/a.js": [
|
|
106
|
+
"import b, { c } from \"./b.js\";",
|
|
107
|
+
"import {",
|
|
108
|
+
" long,",
|
|
109
|
+
" multi,",
|
|
110
|
+
"} from \"./multi.js\";",
|
|
111
|
+
"import \"./side-effect.js\";",
|
|
112
|
+
"const lazy = () => import(\"./lazy.js\");",
|
|
113
|
+
"export * from \"./reexport.js\";",
|
|
114
|
+
"const legacy = require(\"./legacy.js\");",
|
|
115
|
+
"import path from \"node:path\";",
|
|
116
|
+
"import fp from \"lodash/fp\";",
|
|
117
|
+
"import { deep } from \"@scope/pkg/sub\";",
|
|
118
|
+
"import { gone } from \"./no-such-file.js\";",
|
|
119
|
+
].join("\n"),
|
|
120
|
+
"src/b.js": "export default 1;\n",
|
|
121
|
+
"src/multi.js": "export const long = 1;\nexport const multi = 2;\n",
|
|
122
|
+
"src/side-effect.js": "",
|
|
123
|
+
"src/lazy.js": "",
|
|
124
|
+
"src/reexport.js": "",
|
|
125
|
+
"src/legacy.js": "",
|
|
126
|
+
});
|
|
127
|
+
try {
|
|
128
|
+
const files = listFiles(root);
|
|
129
|
+
const graph = parseImportGraph(root, files);
|
|
130
|
+
const from = (to) => graph.edges.filter((e) => e.from === "src/a.js" && e.to === to).length;
|
|
131
|
+
assert.equal(from("src/b.js"), 1);
|
|
132
|
+
assert.equal(from("src/multi.js"), 1, "多行 import 也要成边");
|
|
133
|
+
assert.equal(from("src/side-effect.js"), 1, "副作用 import 成边");
|
|
134
|
+
assert.equal(from("src/lazy.js"), 1, "动态 import 成边");
|
|
135
|
+
assert.equal(from("src/reexport.js"), 1, "再导出成边");
|
|
136
|
+
assert.equal(from("src/legacy.js"), 1, "CJS require 成边");
|
|
137
|
+
assert.ok(!graph.edges.some((e) => e.from === "src/a.js" && e.to.includes("node:path")), "node builtin 不成边");
|
|
138
|
+
assert.ok(graph.external.has("lodash"), "裸说明符按包名聚合");
|
|
139
|
+
assert.ok(graph.external.has("@scope/pkg"), "scoped 包取两段");
|
|
140
|
+
assert.ok(graph.external.has("path") === false, "builtin 不进外部依赖");
|
|
141
|
+
assert.deepEqual(graph.unresolved, [{ from: "src/a.js", spec: "./no-such-file.js" }], "解析失败记账不中断");
|
|
142
|
+
assert.equal(graph.skipped.length, 0);
|
|
143
|
+
} finally {
|
|
144
|
+
rmSync(root, { recursive: true, force: true });
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("import 图:Vue SFC 的 <script> 块参与成边,模板部分不参与", () => {
|
|
149
|
+
const root = makeRepo({
|
|
150
|
+
"comp/App.vue": [
|
|
151
|
+
"<template>",
|
|
152
|
+
" <div :src=\"'./not-a-module.js'\"></div>",
|
|
153
|
+
"</template>",
|
|
154
|
+
"<script>",
|
|
155
|
+
"import helper from \"../src/helper.js\";",
|
|
156
|
+
"export default { components: {} };",
|
|
157
|
+
"</script>",
|
|
158
|
+
].join("\n"),
|
|
159
|
+
"src/helper.js": "export default 1;\n",
|
|
160
|
+
});
|
|
161
|
+
try {
|
|
162
|
+
const graph = parseImportGraph(root, listFiles(root));
|
|
163
|
+
assert.ok(graph.edges.some((e) => e.from === "comp/App.vue" && e.to === "src/helper.js"));
|
|
164
|
+
assert.ok(!graph.edges.some((e) => e.from === "comp/App.vue" && e.to.includes("not-a-module")));
|
|
165
|
+
} finally {
|
|
166
|
+
rmSync(root, { recursive: true, force: true });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("import 图:读不了/解析不了的文件记 skipped,不中断整轮", () => {
|
|
171
|
+
const root = makeRepo({ "ok.js": "import \"./x.js\";", "x.js": "" });
|
|
172
|
+
try {
|
|
173
|
+
// 制造一个清单里存在、磁盘上读不到的文件路径(直接喂给解析器,绕过 listFiles)
|
|
174
|
+
const graph = parseImportGraph(root, ["ok.js", "x.js", "gone/broken.js"]);
|
|
175
|
+
assert.equal(graph.skipped.some((s) => s.file === "gone/broken.js"), true);
|
|
176
|
+
assert.ok(graph.edges.length >= 1, "其他文件不受影响");
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("package.json:scripts/entries/workspaces 收口,坏 JSON 返回 null 不抛", () => {
|
|
183
|
+
const root = makeRepo({
|
|
184
|
+
"package.json": JSON.stringify({
|
|
185
|
+
name: "demo",
|
|
186
|
+
main: "./lib/index.js",
|
|
187
|
+
bin: { demo: "./bin/demo.mjs" },
|
|
188
|
+
exports: { ".": "./lib/main.js" },
|
|
189
|
+
scripts: { test: "node --test" },
|
|
190
|
+
workspaces: ["packages/*", "apps/web"],
|
|
191
|
+
}),
|
|
192
|
+
"broken.js": "",
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
const pkg = readRootPackage(root);
|
|
196
|
+
assert.equal(pkg.name, "demo");
|
|
197
|
+
assert.deepEqual(pkg.scripts, { test: "node --test" });
|
|
198
|
+
assert.ok(pkg.entries.includes("lib/index.js"));
|
|
199
|
+
assert.ok(pkg.entries.includes("bin/demo.mjs"));
|
|
200
|
+
assert.ok(pkg.entries.includes("lib/main.js"));
|
|
201
|
+
// workspacePrefixes 返回排序数组(确定性口径,与文件清单同规矩)
|
|
202
|
+
assert.deepEqual(workspacePrefixes(pkg.workspaces), ["apps/web", "packages"]);
|
|
203
|
+
writeFileSync(join(root, "package.json"), "{oops", "utf8");
|
|
204
|
+
assert.equal(readRootPackage(root), null);
|
|
205
|
+
} finally {
|
|
206
|
+
rmSync(root, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// choro_* 五表的存储层回归锁(实现计划 P2/P7;spec §5)。
|
|
2
|
+
// 锁三件事:新库/空库文件打开即建表(幂等迁移面)、整仓替换跑两遍行数不翻倍
|
|
3
|
+
// (「重跑必须幂等」红线 5 的库侧语义)、唯一索引真实生效(登记表按 realpath 收敛)。
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { DatabaseSync } from "node:sqlite";
|
|
7
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
11
|
+
import { createStore } from "../src/store.js";
|
|
12
|
+
|
|
13
|
+
const CHORO_TABLES = ["choro_repos", "choro_modules", "choro_pages", "choro_claims", "choro_vectors"];
|
|
14
|
+
|
|
15
|
+
// Windows 上 %TEMP% 里刚写完的文件会被 Defender 短暂持锁,rmSync 撞 EPERM 是
|
|
16
|
+
// 瞬时抖动不是用例失败(最小复现:同场景单独跑必过)。清理重试 2s,还不行就
|
|
17
|
+
// 记警告交给系统临时目录清理——清理不在被测范围里,别让它把绿用例标红。
|
|
18
|
+
async function rmTempEventually(dir) {
|
|
19
|
+
for (let i = 0; i < 10; i++) {
|
|
20
|
+
try {
|
|
21
|
+
rmSync(dir, { recursive: true, force: true });
|
|
22
|
+
return;
|
|
23
|
+
} catch (err) {
|
|
24
|
+
if (i === 9) console.warn(`[wiki-store.test] 临时目录清理未完成(${err.code}),留给系统清理:${dir}`);
|
|
25
|
+
await sleep(200);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function openMemory() {
|
|
31
|
+
return createStore(":memory:");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
test("新库打开即建五张 choro 表", () => {
|
|
35
|
+
const store = openMemory();
|
|
36
|
+
for (const table of CHORO_TABLES) {
|
|
37
|
+
const row = store.db.prepare(
|
|
38
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
|
|
39
|
+
).get(table);
|
|
40
|
+
assert.ok(row, `${table} 存在`);
|
|
41
|
+
}
|
|
42
|
+
store.close();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("已存在的空 db 文件(旧库形态)打开后同样补齐表(CREATE IF NOT EXISTS 幂等)", async () => {
|
|
46
|
+
const dir = mkdtempSync(join(tmpdir(), "choro-store-"));
|
|
47
|
+
try {
|
|
48
|
+
const dbPath = join(dir, "memory.db");
|
|
49
|
+
// 空库文件(零表):存量库升级的最简形态。刻意不造「只有 memories 一张裸表」
|
|
50
|
+
// 的夹具——那不是任何发布版本的真实旧库,且 SCHEMA 的 memories 索引会因缺列
|
|
51
|
+
// 拒绝打开(真实旧库带全列,不存在这条路)。
|
|
52
|
+
const legacy = new DatabaseSync(dbPath);
|
|
53
|
+
legacy.close();
|
|
54
|
+
const store = createStore(dbPath);
|
|
55
|
+
for (const table of CHORO_TABLES) {
|
|
56
|
+
const row = store.db.prepare(
|
|
57
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name=?"
|
|
58
|
+
).get(table);
|
|
59
|
+
assert.ok(row, `存量库升级后 ${table} 存在`);
|
|
60
|
+
}
|
|
61
|
+
store.close();
|
|
62
|
+
} finally {
|
|
63
|
+
// 清理放在 finally 但不允许它遮蔽 try 里的真错(EPERM 时真错优先)。
|
|
64
|
+
await rmTempEventually(dir);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("登记表 upsert:同名 realpath 只一行,plan/baseline 随最后一次成功 run 走", () => {
|
|
69
|
+
const store = openMemory();
|
|
70
|
+
const t1 = "2026-11-05T10:00:00.000Z";
|
|
71
|
+
const t2 = "2026-11-05T11:00:00.000Z";
|
|
72
|
+
const first = store.upsertChoroRepo("/r/mneme", "mneme", "fp1", "aaa", t1);
|
|
73
|
+
assert.ok(first.created_at === t1);
|
|
74
|
+
const second = store.upsertChoroRepo("/r/mneme", "mneme", "fp2", "bbb", t2);
|
|
75
|
+
assert.equal(second.id, first.id, "同一 root_path 是同一行");
|
|
76
|
+
assert.equal(second.created_at, t1, "created_at 只在首次插入时落");
|
|
77
|
+
assert.equal(second.plan_fingerprint, "fp2");
|
|
78
|
+
assert.equal(second.git_baseline, "bbb");
|
|
79
|
+
const rows = store.db.prepare("SELECT COUNT(*) AS c FROM choro_repos").get();
|
|
80
|
+
assert.equal(rows.c, 1);
|
|
81
|
+
store.close();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("整仓替换跑两遍行数不翻倍(重跑幂等,红线 5 的库侧)", () => {
|
|
85
|
+
const store = openMemory();
|
|
86
|
+
const repo = store.upsertChoroRepo("/r/x", "x", "fp", "base", "2026-11-05T10:00:00.000Z");
|
|
87
|
+
const snapshot = {
|
|
88
|
+
updatedAt: "2026-11-05T10:00:00.000Z",
|
|
89
|
+
modules: [
|
|
90
|
+
{ slug: "src", title: "src", parentSlug: null, fileCount: 2, fingerprint: "f1" },
|
|
91
|
+
{ slug: "lib", title: "lib", parentSlug: null, fileCount: 1, fingerprint: "f2" },
|
|
92
|
+
],
|
|
93
|
+
pages: [
|
|
94
|
+
{ moduleSlug: "src", relPath: ".chorography/modules/src.md", status: "skeleton", gitBaseline: "base", templateVersion: "1", generatedAt: "t", humanBlockCount: 1, claims: [] },
|
|
95
|
+
{ moduleSlug: "lib", relPath: ".chorography/modules/lib.md", status: "skeleton", gitBaseline: "base", templateVersion: "1", generatedAt: "t", humanBlockCount: 0, claims: [] },
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
store.replaceChoroGraph(repo.id, snapshot);
|
|
99
|
+
store.replaceChoroGraph(repo.id, snapshot); // 重跑同一划分
|
|
100
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM choro_modules").get().c, 2);
|
|
101
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM choro_pages").get().c, 2);
|
|
102
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM choro_claims").get().c, 0);
|
|
103
|
+
store.close();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("划分变化不留孤儿行:旧模块的页面随模块一起被替换掉", () => {
|
|
107
|
+
const store = openMemory();
|
|
108
|
+
const repo = store.upsertChoroRepo("/r/y", "y", "fp", "base", "2026-11-05T10:00:00.000Z");
|
|
109
|
+
const t = "2026-11-05T10:00:00.000Z";
|
|
110
|
+
store.replaceChoroGraph(repo.id, {
|
|
111
|
+
updatedAt: t,
|
|
112
|
+
modules: [{ slug: "old-mod", title: "旧", parentSlug: null, fileCount: 1, fingerprint: "a" }],
|
|
113
|
+
pages: [{ moduleSlug: "old-mod", relPath: ".chorography/modules/old-mod.md", status: "skeleton", gitBaseline: "base", templateVersion: "1", generatedAt: t, humanBlockCount: 0, claims: [] }],
|
|
114
|
+
});
|
|
115
|
+
store.replaceChoroGraph(repo.id, {
|
|
116
|
+
updatedAt: t,
|
|
117
|
+
modules: [{ slug: "new-mod", title: "新", parentSlug: null, fileCount: 1, fingerprint: "b" }],
|
|
118
|
+
pages: [{ moduleSlug: "new-mod", relPath: ".chorography/modules/new-mod.md", status: "skeleton", gitBaseline: "base", templateVersion: "1", generatedAt: t, humanBlockCount: 0, claims: [] }],
|
|
119
|
+
});
|
|
120
|
+
assert.equal(store.db.prepare("SELECT COUNT(*) AS c FROM choro_modules").get().c, 1);
|
|
121
|
+
const slug = store.db.prepare("SELECT slug FROM choro_modules").get().slug;
|
|
122
|
+
assert.equal(slug, "new-mod");
|
|
123
|
+
const orphanPages = store.db.prepare(
|
|
124
|
+
"SELECT COUNT(*) AS c FROM choro_pages WHERE module_id NOT IN (SELECT id FROM choro_modules)"
|
|
125
|
+
).get().c;
|
|
126
|
+
assert.equal(orphanPages, 0, "没有指向已删模块的孤儿页");
|
|
127
|
+
store.close();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("父子链两段式落库:父在后也接得上(顺序无关)", () => {
|
|
131
|
+
const store = openMemory();
|
|
132
|
+
const repo = store.upsertChoroRepo("/r/z", "z", "fp", "base", "2026-11-05T10:00:00.000Z");
|
|
133
|
+
store.replaceChoroGraph(repo.id, {
|
|
134
|
+
updatedAt: "2026-11-05T10:00:00.000Z",
|
|
135
|
+
modules: [
|
|
136
|
+
{ slug: "src-store", title: "store", parentSlug: "src", fileCount: 1, fingerprint: "a" },
|
|
137
|
+
{ slug: "src", title: "src", parentSlug: null, fileCount: 1, fingerprint: "b" },
|
|
138
|
+
],
|
|
139
|
+
pages: [],
|
|
140
|
+
});
|
|
141
|
+
const rows = store.db.prepare("SELECT slug, parent_id FROM choro_modules ORDER BY slug").all();
|
|
142
|
+
const bySlug = new Map(rows.map((r) => [r.slug, r.parent_id]));
|
|
143
|
+
const idOf = (slug) => store.db.prepare("SELECT id FROM choro_modules WHERE slug=?").get(slug).id;
|
|
144
|
+
assert.equal(bySlug.get("src-store"), idOf("src"), "子模块指向父模块行");
|
|
145
|
+
assert.equal(bySlug.get("src"), null);
|
|
146
|
+
store.close();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("唯一索引真实生效:重复 (repo_id, slug) 撞库报 IntegrityError(本仓惯例:UNIQUE 走独立索引)", () => {
|
|
150
|
+
const store = openMemory();
|
|
151
|
+
const repo = store.upsertChoroRepo("/r/u", "u", "fp", "base", "2026-11-05T10:00:00.000Z");
|
|
152
|
+
const t = "2026-11-05T10:00:00.000Z";
|
|
153
|
+
store.replaceChoroGraph(repo.id, {
|
|
154
|
+
updatedAt: t,
|
|
155
|
+
modules: [{ slug: "a", title: "a", parentSlug: null, fileCount: 1, fingerprint: "a" }],
|
|
156
|
+
pages: [],
|
|
157
|
+
});
|
|
158
|
+
assert.throws(() => {
|
|
159
|
+
store.db.prepare(
|
|
160
|
+
"INSERT INTO choro_modules (repo_id, slug, title, file_count, updated_at) VALUES (?, 'a', 'dup', 1, ?)"
|
|
161
|
+
).run(repo.id, t);
|
|
162
|
+
}, /UNIQUE/);
|
|
163
|
+
store.close();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("claims 经 replaceChoroGraph 落库(M2 的路径,M0 只验形状)", () => {
|
|
167
|
+
const store = openMemory();
|
|
168
|
+
const repo = store.upsertChoroRepo("/r/c", "c", "fp", "base", "2026-11-05T10:00:00.000Z");
|
|
169
|
+
store.replaceChoroGraph(repo.id, {
|
|
170
|
+
updatedAt: "2026-11-05T10:00:00.000Z",
|
|
171
|
+
modules: [{ slug: "m", title: "m", parentSlug: null, fileCount: 1, fingerprint: "a" }],
|
|
172
|
+
pages: [{
|
|
173
|
+
moduleSlug: "m", relPath: ".chorography/modules/m.md", status: "enriched",
|
|
174
|
+
gitBaseline: "base", templateVersion: "1", generatedAt: "t", humanBlockCount: 0,
|
|
175
|
+
claims: [{
|
|
176
|
+
statement: "store.js 是唯一的手写 schema 位置",
|
|
177
|
+
kind: "architecture",
|
|
178
|
+
sourcePath: "src/store.js",
|
|
179
|
+
startLine: 25,
|
|
180
|
+
endLine: 30,
|
|
181
|
+
confidence: "high",
|
|
182
|
+
}],
|
|
183
|
+
}],
|
|
184
|
+
});
|
|
185
|
+
const claim = store.db.prepare("SELECT statement, kind, source_path, start_line FROM choro_claims").get();
|
|
186
|
+
assert.equal(claim.kind, "architecture");
|
|
187
|
+
assert.equal(claim.source_path, "src/store.js");
|
|
188
|
+
assert.equal(claim.start_line, 25);
|
|
189
|
+
store.close();
|
|
190
|
+
});
|