@modusensus/dsh-mneme 0.10.0 → 0.11.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.
Files changed (79) hide show
  1. package/README.md +11 -3
  2. package/bin/dsh-mneme-choro.mjs +254 -0
  3. package/docs/CONFIGURATION.md +8 -1
  4. package/lib/api-standalone.js +17 -21
  5. package/lib/api.js +298 -12
  6. package/lib/bootstrap-http.js +105 -0
  7. package/lib/bootstrap.js +5 -1
  8. package/lib/choro-command.js +104 -0
  9. package/lib/client.js +1153 -106
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/graph/anchoring.js +7 -2
  13. package/lib/index.js +50 -4
  14. package/lib/inject.js +15 -3
  15. package/lib/lang.js +54 -0
  16. package/lib/quality-filter.js +48 -12
  17. package/lib/service.js +72 -8
  18. package/lib/settings.js +8 -1
  19. package/lib/store.js +657 -0
  20. package/lib/tools.js +127 -1
  21. package/lib/wiki/inject.js +164 -0
  22. package/lib/wiki/partition.js +200 -0
  23. package/lib/wiki/plan.js +214 -0
  24. package/lib/wiki/queries.js +220 -0
  25. package/lib/wiki/render.js +187 -0
  26. package/lib/wiki/scan.js +371 -0
  27. package/lib/wiki/t0.js +463 -0
  28. package/lib/wiki/t1.js +496 -0
  29. package/lib/write-admission.js +5 -3
  30. package/package.json +4 -2
  31. package/src/api-standalone.js +17 -21
  32. package/src/api.js +298 -12
  33. package/src/bootstrap-http.js +105 -0
  34. package/src/bootstrap.js +5 -1
  35. package/src/choro-command.js +104 -0
  36. package/src/config-keys.js +144 -0
  37. package/src/config.js +28 -0
  38. package/src/graph/anchoring.js +7 -2
  39. package/src/index.js +50 -4
  40. package/src/inject.js +15 -3
  41. package/src/lang.js +54 -0
  42. package/src/quality-filter.js +48 -12
  43. package/src/service.js +72 -8
  44. package/src/settings.js +8 -1
  45. package/src/store.js +657 -0
  46. package/src/tools.js +127 -1
  47. package/src/wiki/inject.js +164 -0
  48. package/src/wiki/partition.js +200 -0
  49. package/src/wiki/plan.js +214 -0
  50. package/src/wiki/queries.js +220 -0
  51. package/src/wiki/render.js +187 -0
  52. package/src/wiki/scan.js +371 -0
  53. package/src/wiki/t0.js +463 -0
  54. package/src/wiki/t1.js +496 -0
  55. package/src/write-admission.js +5 -3
  56. package/test/api-routes-gaps.test.js +131 -1
  57. package/test/api.test.js +7 -1
  58. package/test/bootstrap.test.js +8 -0
  59. package/test/choro-command.test.js +218 -0
  60. package/test/client.test.js +820 -19
  61. package/test/config-keys-routes.test.js +233 -0
  62. package/test/contrast.test.js +14 -7
  63. package/test/graph-boosted-relations.test.js +189 -0
  64. package/test/graph-multipath.test.js +269 -0
  65. package/test/inject.test.js +51 -0
  66. package/test/llm-audit.test.js +29 -1
  67. package/test/quality-backfill.test.js +290 -0
  68. package/test/quality-filter.test.js +113 -4
  69. package/test/service.test.js +1 -1
  70. package/test/wiki-api.test.js +153 -0
  71. package/test/wiki-inject.test.js +265 -0
  72. package/test/wiki-partition.test.js +112 -0
  73. package/test/wiki-plan.test.js +80 -0
  74. package/test/wiki-queries.test.js +187 -0
  75. package/test/wiki-scan.test.js +208 -0
  76. package/test/wiki-store.test.js +190 -0
  77. package/test/wiki-t0.test.js +380 -0
  78. package/test/wiki-t1.test.js +235 -0
  79. package/test/wiki-tools.test.js +121 -0
@@ -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
+ });