@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,265 @@
|
|
|
1
|
+
// wiki/inject.js 的回归锁(M3 开局注入)。全部用例走真实 T0(fixture 仓库 apply)
|
|
2
|
+
// 建数据,不手插 DB 行——这一层测的正是「DB 索引 × cwd 反查」的对账。
|
|
3
|
+
// 锁的点:档位解析(含轻量档压成 off)、只注标题级、light 的 6 行上限与排序、
|
|
4
|
+
// 字符预算的逐行截断(不许半行)、空串语义(不注入 = 空串而非占位)、
|
|
5
|
+
// 渲染失败绝不抛、缓存随 updated_at 失效、转义只做在注入层。
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
12
|
+
import { runChoroT0 } from "../src/wiki/t0.js";
|
|
13
|
+
import { createStore } from "../src/store.js";
|
|
14
|
+
import { createChoroQueries } from "../src/wiki/queries.js";
|
|
15
|
+
import {
|
|
16
|
+
createChoroInject,
|
|
17
|
+
renderChoroBlock,
|
|
18
|
+
resolveInjectTier,
|
|
19
|
+
LIGHT_MODULE_LIMIT,
|
|
20
|
+
MAX_BLOCK_CHARS
|
|
21
|
+
} from "../src/wiki/inject.js";
|
|
22
|
+
import { escapePromptVars } from "../src/user-settings.js";
|
|
23
|
+
|
|
24
|
+
const FIXED_NOW = () => "2026-11-05T00:00:00.000Z";
|
|
25
|
+
|
|
26
|
+
/** 造仓库:files = [[相对路径, 内容], ...],返回根目录。 */
|
|
27
|
+
function makeFixture(files = null) {
|
|
28
|
+
const root = mkdtempSync(join(tmpdir(), "choro-inject-"));
|
|
29
|
+
const items = files ?? [
|
|
30
|
+
["package.json", JSON.stringify({ name: "ifix", scripts: { test: "node --test" } }, null, 2) + "\n"],
|
|
31
|
+
["src/a.js", 'import { b } from "./b.js";\nexport const a = () => b();\n'],
|
|
32
|
+
["src/b.js", 'import { b2 } from "./b2.js";\nexport const b = () => b2();\n'],
|
|
33
|
+
["src/b2.js", "export const b2 = () => 1;\n"]
|
|
34
|
+
];
|
|
35
|
+
for (const [rel, content] of items) {
|
|
36
|
+
const abs = join(root, rel);
|
|
37
|
+
mkdirSync(join(abs, ".."), { recursive: true });
|
|
38
|
+
writeFileSync(abs, content, "utf8");
|
|
39
|
+
}
|
|
40
|
+
execFileSync("git", ["init", "-b", "main"], { cwd: root, stdio: "pipe" });
|
|
41
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "add", "-A"], { cwd: root, stdio: "pipe" });
|
|
42
|
+
execFileSync("git", ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init", "--no-gpg-sign"], { cwd: root, stdio: "pipe" });
|
|
43
|
+
return root;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function rmTempBestEffort(dir) {
|
|
47
|
+
for (let i = 0; i < 5; i++) {
|
|
48
|
+
try {
|
|
49
|
+
rmSync(dir, { recursive: true, force: true });
|
|
50
|
+
return;
|
|
51
|
+
} catch {
|
|
52
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
console.warn("[wiki-inject.test] 临时目录清理未完成:" + dir);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function setup(config = {}, over = {}) {
|
|
59
|
+
const root = makeFixture(over.files ?? null);
|
|
60
|
+
const store = createStore(":memory:");
|
|
61
|
+
runChoroT0({ repoRoot: root, store, apply: true, now: FIXED_NOW });
|
|
62
|
+
const choro = createChoroQueries({ store });
|
|
63
|
+
return { root, store, choro };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ON = { chorographyEnabled: true, chorographyInject: "light" };
|
|
67
|
+
|
|
68
|
+
test("resolveInjectTier:总闸关 / 轻量档 / 非法值 / 缺省的四条口径", () => {
|
|
69
|
+
assert.equal(resolveInjectTier(null), "off");
|
|
70
|
+
assert.equal(resolveInjectTier({}), "off", "chorographyEnabled 默认关 → 不注入");
|
|
71
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: false }), "off");
|
|
72
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: true }), "light", "缺 chorographyInject 时按 light");
|
|
73
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: true, chorographyInject: "full" }), "full");
|
|
74
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: true, chorographyInject: "off" }), "off");
|
|
75
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: true, chorographyInject: "bogus" }), "light", "非法值退回缺省,不静默关掉");
|
|
76
|
+
// 轻量档在消费侧压成 off:LIGHT_MODE_OFF 对平铺键赋 false,枚举键进不去(P26)
|
|
77
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: true, lightMode: true }), "off");
|
|
78
|
+
assert.equal(resolveInjectTier({ chorographyEnabled: false, lightMode: false }), "off");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("renderChoroBlock:只注标题级,模块名 / 文件数 / 页状态 / 页相对路径都在,正文一个字都不带", () => {
|
|
82
|
+
const { root, store, choro } = setup(ON);
|
|
83
|
+
try {
|
|
84
|
+
const repo = choro.repoByCwd(root);
|
|
85
|
+
const block = renderChoroBlock(choro.outline(repo.id), "full", "zh");
|
|
86
|
+
assert.ok(block.includes("[仓库地志]"), "有块头");
|
|
87
|
+
assert.ok(block.includes("ifix"), "仓库名");
|
|
88
|
+
assert.ok(block.includes("生成于 2026-11-05"), "生成日期:地志是快照不是真相,读者得知道它可能是旧的");
|
|
89
|
+
assert.ok(block.includes(".chorography/modules/src.md"), "页相对路径");
|
|
90
|
+
assert.ok(block.includes("(3 文件)"), "文件数");
|
|
91
|
+
// 「只注标题级」的机制锁:正文的节标题与说明行一个字都不许出现在注入块里
|
|
92
|
+
assert.equal(block.includes("## 文件清单"), false);
|
|
93
|
+
assert.equal(block.includes("确定性骨架"), false);
|
|
94
|
+
assert.equal(block.includes("| 文件 |"), false);
|
|
95
|
+
} finally {
|
|
96
|
+
store.close();
|
|
97
|
+
rmTempBestEffort(root);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("renderChoroBlock:light 只列 6 行并明说还有几个,full 列全表", () => {
|
|
102
|
+
// 9 个目录 → 9 个模块,超过 light 的 6 行上限
|
|
103
|
+
const files = [["package.json", JSON.stringify({ name: "bigfix" }) + "\n"]];
|
|
104
|
+
for (let i = 0; i < 9; i++) files.push([`p${i}/index.js`, `export const v${i} = ${i};\n`]);
|
|
105
|
+
const { root, store, choro } = setup(ON, { files });
|
|
106
|
+
try {
|
|
107
|
+
const repo = choro.repoByCwd(root);
|
|
108
|
+
const outline = choro.outline(repo.id);
|
|
109
|
+
assert.ok(outline.modules.length >= LIGHT_MODULE_LIMIT + 1, "夹具要多于上限才测得出截断");
|
|
110
|
+
const light = renderChoroBlock(outline, "light", "zh");
|
|
111
|
+
const lightLines = light.split("\n");
|
|
112
|
+
assert.equal(lightLines.length, LIGHT_MODULE_LIMIT + 2, "块头 1 + 模块 6 + 「还有 N 个」1");
|
|
113
|
+
assert.ok(light.includes("另有 " + (outline.modules.length - LIGHT_MODULE_LIMIT) + " 个模块"), "静默截断比明说更坏");
|
|
114
|
+
const full = renderChoroBlock(outline, "full", "zh");
|
|
115
|
+
assert.equal(full.includes("另有"), false, "full 不该有截断提示");
|
|
116
|
+
assert.equal(full.split("\n").length, outline.modules.length + 1);
|
|
117
|
+
} finally {
|
|
118
|
+
store.close();
|
|
119
|
+
rmTempBestEffort(root);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("renderChoroBlock:字符预算逐行截断,绝不切出半行", () => {
|
|
124
|
+
// 造足够多的模块让 full 撞 6000 字符上限
|
|
125
|
+
// 60 个「长名」模块就够撞上限:slug 取自目录名,目录名越长每条提纲行越长,
|
|
126
|
+
// 比堆 400 个短名文件快一个数量级(夹具成本不该压在 T0 的 git 扫描上)。
|
|
127
|
+
const files = [["package.json", JSON.stringify({ name: "hugefix" }) + "\n"]];
|
|
128
|
+
const longName = "very-deep-directory-name-that-keeps-going-for-quite-a-while-indeed";
|
|
129
|
+
for (let i = 0; i < 60; i++) files.push([`mod-${i}/${longName}/index.js`, `export const v = ${i};\n`]);
|
|
130
|
+
const { root, store, choro } = setup(ON, { files });
|
|
131
|
+
try {
|
|
132
|
+
const repo = choro.repoByCwd(root);
|
|
133
|
+
const block = renderChoroBlock(choro.outline(repo.id), "full", "zh");
|
|
134
|
+
assert.ok(block.length <= MAX_BLOCK_CHARS.full, "不超预算:" + block.length);
|
|
135
|
+
assert.ok(block.length > 0);
|
|
136
|
+
for (const line of block.split("\n")) {
|
|
137
|
+
assert.equal(line.startsWith("- ") || line.startsWith("["), true, "每一行都是完整的一行:" + line.slice(0, 40));
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
store.close();
|
|
141
|
+
rmTempBestEffort(root);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("renderChoroBlock:空提纲 / 空模块返回空串(不注入 = 空串,宿主会过滤掉不占位)", () => {
|
|
146
|
+
assert.equal(renderChoroBlock(null, "light", "zh"), "");
|
|
147
|
+
assert.equal(renderChoroBlock({ repo: { name: "x" }, modules: [] }, "light", "zh"), "");
|
|
148
|
+
assert.equal(renderChoroBlock({ repo: { name: "x" } }, "light", "zh"), "");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("createChoroInject:按 cwd 命中仓库才出块,未命中与总闸关都是空串", () => {
|
|
152
|
+
const { root, store, choro } = setup(ON);
|
|
153
|
+
try {
|
|
154
|
+
const on = createChoroInject({ choro, config: ON, language: () => "zh" });
|
|
155
|
+
assert.equal(on.tier, "light");
|
|
156
|
+
assert.ok(on.text(root).includes("[仓库地志]"));
|
|
157
|
+
assert.ok(on.text(join(root, "src")).includes("[仓库地志]"), "子目录也命中");
|
|
158
|
+
assert.equal(on.text(join(root, "no-such-dir")), "", "不存在的 cwd → 空串");
|
|
159
|
+
assert.equal(on.text(null), "", "没有 cwd → 空串");
|
|
160
|
+
const off = createChoroInject({ choro, config: { chorographyEnabled: false } });
|
|
161
|
+
assert.equal(off.text(root), "", "总闸关 → 空串");
|
|
162
|
+
const noChoro = createChoroInject({ choro: null, config: ON });
|
|
163
|
+
assert.equal(noChoro.text(root), "", "没有读侧对象也不抛");
|
|
164
|
+
} finally {
|
|
165
|
+
store.close();
|
|
166
|
+
rmTempBestEffort(root);
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("createChoroInject:渲染异常绝不抛,退化成空串", () => {
|
|
171
|
+
const warnings = [];
|
|
172
|
+
const boom = {
|
|
173
|
+
repoByCwd: () => ({ id: 1, updated_at: "2026-11-05T00:00:00.000Z" }),
|
|
174
|
+
outline: () => {
|
|
175
|
+
throw new Error("db is gone");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const inject = createChoroInject({
|
|
179
|
+
choro: boom,
|
|
180
|
+
config: ON,
|
|
181
|
+
logger: { warn: (m) => warnings.push(m) },
|
|
182
|
+
language: () => "zh"
|
|
183
|
+
});
|
|
184
|
+
assert.equal(inject.text("/whatever"), "", "附属信息块不能把整轮 systemPrompt 搞崩");
|
|
185
|
+
assert.equal(warnings.length, 1);
|
|
186
|
+
assert.ok(warnings[0].includes("db is gone"));
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("createChoroInject:每轮查一次库取缓存键(不 fork git),同键不重渲染,换语言换键", () => {
|
|
190
|
+
const { root, store, choro } = setup(ON);
|
|
191
|
+
try {
|
|
192
|
+
let lang = "zh";
|
|
193
|
+
let calls = 0;
|
|
194
|
+
const spy = {
|
|
195
|
+
repoByCwd: (cwd) => choro.repoByCwd(cwd),
|
|
196
|
+
outline: (id) => {
|
|
197
|
+
calls += 1;
|
|
198
|
+
return choro.outline(id);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
const inject = createChoroInject({ choro: spy, config: ON, language: () => lang });
|
|
202
|
+
const first = inject.text(root);
|
|
203
|
+
const second = inject.text(root);
|
|
204
|
+
// 键要含页级生成时间,所以每轮必须查一次库——这是索引查询(几十微秒),
|
|
205
|
+
// 不是 P29 要避开的 fork git / stat 磁盘。这里锁的就是「查库了,但没重渲染」:
|
|
206
|
+
// 两次返回逐字节相同,说明缓存在生效而不是每次重新拼。
|
|
207
|
+
assert.equal(calls, 2, "每轮查一次库取缓存键");
|
|
208
|
+
assert.equal(second, first, "同一个键命中缓存,渲染结果一致");
|
|
209
|
+
lang = "en";
|
|
210
|
+
const en = inject.text(root);
|
|
211
|
+
assert.equal(calls, 3, "换语言换键");
|
|
212
|
+
assert.ok(en.includes("[Repo atlas]"));
|
|
213
|
+
inject.dispose();
|
|
214
|
+
assert.equal(inject.text(root).includes("[Repo atlas]"), true, "dispose 后仍能重算(只是丢了缓存)");
|
|
215
|
+
} finally {
|
|
216
|
+
store.close();
|
|
217
|
+
rmTempBestEffort(root);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("createChoroInject:注入层的文本过 escapePromptVars,名字里的花括号不许崩宿主装配", () => {
|
|
222
|
+
const outline = {
|
|
223
|
+
repo: { id: 1, name: "weird{{name}}repo", updatedAt: "2026-11-05T00:00:00.000Z" },
|
|
224
|
+
generatedAt: "2026-11-05T00:00:00.000Z",
|
|
225
|
+
modules: [{ slug: "a", title: "m{{x}}", fileCount: 1, parentSlug: null, status: "skeleton", relPath: ".chorography/modules/a.md" }]
|
|
226
|
+
};
|
|
227
|
+
const fake = { repoByCwd: () => ({ id: 1, updated_at: "2026-11-05T00:00:00.000Z" }), outline: () => outline };
|
|
228
|
+
const inject = createChoroInject({ choro: fake, config: ON, language: () => "zh" });
|
|
229
|
+
const text = inject.text("/x");
|
|
230
|
+
// 契约不是「转义成某个具体形态」,而是「转义后不再留下任何成对花括号」:宿主
|
|
231
|
+
// 的 interpolate() 只认 /\{\{([^{}]*)\}\}/,只要 {{ 与 }} 都不成对出现,
|
|
232
|
+
// 就永远不会被当成变量引用。所以锁这两个子串,不锁反斜杠落在哪一格。
|
|
233
|
+
assert.equal(text.includes("{{"), false, "残留 {{ 会让宿主 interpolate 抛错崩整轮装配");
|
|
234
|
+
assert.equal(text.includes("}}"), false, "残留 }} 同上");
|
|
235
|
+
// 与转义函数逐字节一致:注入层是唯一做转义的地方,别的层不许再包一层。
|
|
236
|
+
assert.equal(text, escapePromptVars(ON, renderChoroBlock(outline, "light", "zh")));
|
|
237
|
+
// 未转义的渲染函数本身保持原样——/choro 的 UI 回执不进宿主模板,转义了反而难看
|
|
238
|
+
assert.ok(renderChoroBlock(outline, "light", "zh").includes("{{name}}"));
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// T1 富化只推 choro_pages.generated_at,不动 choro_repos.updated_at(store.js:
|
|
242
|
+
// markChoroPageEnriched 的注释写明「T0 整仓替换与 T1 页级富化并存」)。缓存键若挂
|
|
243
|
+
// 在仓库行上,用户刚跑完 `--t1 --apply`,注入块还会写着「骨架」而 /choro(无缓存)
|
|
244
|
+
// 已经报「已有语义段」——两个面说同一件事却给出不同答案。所以锁这条:页级状态一变,
|
|
245
|
+
// 下一轮的注入块必须跟着变,且不是靠 dispose / 重启。
|
|
246
|
+
test("createChoroInject:T1 富化只推页级 generated_at,缓存也必须失效(不能停在「骨架」)", () => {
|
|
247
|
+
const { root, store, choro } = setup(ON);
|
|
248
|
+
try {
|
|
249
|
+
const inject = createChoroInject({ choro, config: ON, language: () => "zh" });
|
|
250
|
+
const before = inject.text(root);
|
|
251
|
+
assert.equal(before.includes("已有语义段"), false, "富化前不该出现 enriched 标签");
|
|
252
|
+
const repo = choro.repoByCwd(root);
|
|
253
|
+
const repoRowBefore = store.choroRepoByRoot(root);
|
|
254
|
+
const page = store.choroPageBySlug(repo.id, "src");
|
|
255
|
+
assert.ok(page, "夹具必须切出 src 模块,否则这条用例测的是空气");
|
|
256
|
+
store.markChoroPageEnriched(page.id, "2026-11-06T09:00:00.000Z");
|
|
257
|
+
// 前提断言:富化确实没有碰仓库行——不然这条用例会假绿(更新的是 updated_at 也就命中)
|
|
258
|
+
assert.equal(store.choroRepoByRoot(root).updated_at, repoRowBefore.updated_at, "T1 富化不该动 choro_repos.updated_at");
|
|
259
|
+
const after = inject.text(root);
|
|
260
|
+
assert.equal(after.includes("已有语义段"), true, "页级生成时间变了,注入块必须重算");
|
|
261
|
+
} finally {
|
|
262
|
+
store.close();
|
|
263
|
+
rmTempBestEffort(root);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// partition.js 的回归锁:划分确定性(同输入两次逐项相同——spec §9 可重建不变量的
|
|
2
|
+
// 前半段)、兄弟组内聚合并、超限逐级下切、slug 稳定、父子链按目录包含。
|
|
3
|
+
import test from "node:test";
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { partitionModules } from "../src/wiki/partition.js";
|
|
6
|
+
|
|
7
|
+
function graph(edges) {
|
|
8
|
+
const files = [...new Set(edges.flatMap((e) => [e.from, e.to]))].sort();
|
|
9
|
+
return { files, graph: { edges, external: new Map() } };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
test("划分是确定性的:同输入两次结果逐项相同", () => {
|
|
13
|
+
const input = graph([
|
|
14
|
+
{ from: "src/a.js", to: "src/b.js" },
|
|
15
|
+
{ from: "lib/c.js", to: "src/a.js" },
|
|
16
|
+
]);
|
|
17
|
+
const one = partitionModules({ ...input, maxFilesPerPage: 40, repoName: "demo" });
|
|
18
|
+
const two = partitionModules({ ...input, maxFilesPerPage: 40, repoName: "demo" });
|
|
19
|
+
assert.deepEqual(one, two);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("根直属文件成 root 模块;目录组各自成页;互不相干的组不合并", () => {
|
|
23
|
+
// 显式给根直属文件(graph() 只从边推文件,不含根组)。
|
|
24
|
+
const files = ["README.md", "package.json", "src/a.js", "src/b.js", "tools/x.js", "tools/y.js"];
|
|
25
|
+
const g = {
|
|
26
|
+
edges: [
|
|
27
|
+
{ from: "src/a.js", to: "src/b.js" },
|
|
28
|
+
{ from: "tools/x.js", to: "tools/y.js" },
|
|
29
|
+
],
|
|
30
|
+
external: new Map(),
|
|
31
|
+
};
|
|
32
|
+
const mods = partitionModules({ files, graph: g, maxFilesPerPage: 40, repoName: "demo" });
|
|
33
|
+
const slugs = mods.map((m) => m.slug).sort();
|
|
34
|
+
assert.deepEqual(slugs, ["root", "src", "tools"]);
|
|
35
|
+
const root = mods.find((m) => m.slug === "root");
|
|
36
|
+
assert.deepEqual(root.files.sort(), ["README.md", "package.json"]);
|
|
37
|
+
assert.equal(root.title, "demo(仓库根)");
|
|
38
|
+
const src = mods.find((m) => m.slug === "src");
|
|
39
|
+
assert.deepEqual(src.files.sort(), ["src/a.js", "src/b.js"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("兄弟组交叉边 ≥3 才合并(内聚判据要能口述,V2 人判用)", () => {
|
|
43
|
+
// utils 与 src 之间 3 条边 → 合并成一个模块
|
|
44
|
+
const strong = graph([
|
|
45
|
+
{ from: "src/a.js", to: "utils/u1.js" },
|
|
46
|
+
{ from: "src/b.js", to: "utils/u1.js" },
|
|
47
|
+
{ from: "src/a.js", to: "utils/u2.js" },
|
|
48
|
+
]);
|
|
49
|
+
const merged = partitionModules({ ...strong, maxFilesPerPage: 40, repoName: "demo" });
|
|
50
|
+
assert.equal(merged.length, 1, "3 条交叉边触发合并");
|
|
51
|
+
// 只有 2 条边 → 各自独立
|
|
52
|
+
const weak = graph([
|
|
53
|
+
{ from: "src/a.js", to: "utils/u1.js" },
|
|
54
|
+
{ from: "src/b.js", to: "utils/u1.js" },
|
|
55
|
+
]);
|
|
56
|
+
const separate = partitionModules({ ...weak, maxFilesPerPage: 40, repoName: "demo" });
|
|
57
|
+
assert.equal(separate.length, 2, "2 条边不合并");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("超 max_files_per_page 的组逐级下切,叶子组仍超限按字典序切块", () => {
|
|
61
|
+
const files = [];
|
|
62
|
+
const edges = [];
|
|
63
|
+
for (let i = 0; i < 7; i++) {
|
|
64
|
+
files.push(`big/f${i}.js`);
|
|
65
|
+
if (i > 0) edges.push({ from: "big/f0.js", to: `big/f${i}.js` });
|
|
66
|
+
}
|
|
67
|
+
const mods = partitionModules({ files, graph: { edges, external: new Map() }, maxFilesPerPage: 3, repoName: "demo" });
|
|
68
|
+
const total = mods.reduce((sum, m) => sum + m.fileCount, 0);
|
|
69
|
+
assert.equal(total, 7, "切分不丢文件");
|
|
70
|
+
assert.ok(mods.every((m) => m.fileCount <= 3), "每块都不超上限");
|
|
71
|
+
assert.ok(mods.length >= 3, "7 个文件、上限 3 → 至少 3 块");
|
|
72
|
+
// slug 唯一(切块组的 #2 序号后缀避免撞名)
|
|
73
|
+
const slugs = mods.map((m) => m.slug);
|
|
74
|
+
assert.equal(new Set(slugs).size, slugs.length, "slug 无重复");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("父链按目录包含取最近祖先(spec frontmatter.parents / DB parent_id 同源)", () => {
|
|
78
|
+
const { files, graph: g } = graph([
|
|
79
|
+
{ from: "src/store/index.js", to: "src/store/util.js" },
|
|
80
|
+
{ from: "src/wiki/t0.js", to: "src/store/index.js" },
|
|
81
|
+
]);
|
|
82
|
+
const mods = partitionModules({ files, graph: g, maxFilesPerPage: 40, repoName: "demo" });
|
|
83
|
+
// 初稿是两级目录段:src/store、src/wiki;「src」本身不是组 → 无父链也合法,
|
|
84
|
+
// 但 src/store 与 src/wiki 之间没有包含关系(同层),互不为父。
|
|
85
|
+
const store = mods.find((m) => m.dir === "src/store");
|
|
86
|
+
const wiki = mods.find((m) => m.dir === "src/wiki");
|
|
87
|
+
assert.ok(store && wiki);
|
|
88
|
+
assert.equal(store.parentSlug, null, "同级目录不是父子");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("模块指纹随文件集/边集变化(DB 记账与过期检测的输入)", () => {
|
|
92
|
+
const a = partitionModules({ ...graph([{ from: "src/a.js", to: "src/b.js" }]), maxFilesPerPage: 40, repoName: "d" });
|
|
93
|
+
const b = partitionModules({
|
|
94
|
+
files: ["src/a.js", "src/b.js", "src/c.js"],
|
|
95
|
+
graph: { edges: [{ from: "src/a.js", to: "src/b.js" }], external: new Map() },
|
|
96
|
+
maxFilesPerPage: 40,
|
|
97
|
+
repoName: "d",
|
|
98
|
+
});
|
|
99
|
+
const fa = a.find((m) => m.slug === "src").fingerprint;
|
|
100
|
+
const fb = b.find((m) => m.slug === "src").fingerprint;
|
|
101
|
+
assert.notEqual(fa, fb, "文件集变了指纹必须变");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("外部依赖按模块聚合(骨架页「外部依赖」一节的数据源)", () => {
|
|
105
|
+
const files = ["src/a.js", "src/b.js", "lib/c.js"];
|
|
106
|
+
const external = new Map([["left-pad", new Set(["src/a.js", "src/b.js"])]]);
|
|
107
|
+
const mods = partitionModules({ files, graph: { edges: [], external }, maxFilesPerPage: 40, repoName: "d" });
|
|
108
|
+
const src = mods.find((m) => m.dir === "src");
|
|
109
|
+
assert.deepEqual(src.externalDeps, [{ pkg: "left-pad", count: 2 }]);
|
|
110
|
+
const lib = mods.find((m) => m.dir === "lib");
|
|
111
|
+
assert.deepEqual(lib.externalDeps, []);
|
|
112
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// plan.yaml 校验的回归锁(spec §4 / §9;实现计划 P1)。
|
|
2
|
+
// 锁三类回归:未知键必须整轮拒绝(拼错的键等于失效的控制,不能静默忽略)、
|
|
3
|
+
// 类型错误逐条收集一次报完(人一次改完,不逐个试错)、无 plan.yaml 走默认启发式。
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { loadPlan, planFingerprint, PLAN_ALLOWED_KEYS, PlanInvalidError } from "../src/wiki/plan.js";
|
|
7
|
+
|
|
8
|
+
test("无 plan.yaml / 空文件走默认启发式", () => {
|
|
9
|
+
const a = loadPlan(null);
|
|
10
|
+
assert.equal(a.plan.maxFilesPerPage, 40);
|
|
11
|
+
assert.deepEqual(a.plan.exclude, []);
|
|
12
|
+
assert.equal(a.plan.pages, null);
|
|
13
|
+
assert.deepEqual(a.plan.templates, {});
|
|
14
|
+
// 「没带 plan」也是可记账状态:指纹 = 空串 sha256
|
|
15
|
+
assert.equal(a.fingerprint, planFingerprint(""));
|
|
16
|
+
const b = loadPlan(" \n");
|
|
17
|
+
assert.equal(b.plan.maxFilesPerPage, 40);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("未知键整轮拒绝,报出键名与允许列表(spec 降级表第一行)", () => {
|
|
21
|
+
assert.throws(
|
|
22
|
+
() => loadPlan("includes:\n - src\n"),
|
|
23
|
+
(err) => {
|
|
24
|
+
assert.ok(err instanceof PlanInvalidError);
|
|
25
|
+
assert.ok(err.message.includes("includes"), "报错要点名拼错的键");
|
|
26
|
+
assert.ok(err.message.includes("include"), "有相近键时给改正提示");
|
|
27
|
+
assert.ok(err.message.includes("允许的顶层键"));
|
|
28
|
+
for (const k of PLAN_ALLOWED_KEYS) assert.ok(err.message.includes(k));
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("完全不相干的未知键不给误导性提示", () => {
|
|
35
|
+
assert.throws(
|
|
36
|
+
() => loadPlan("zzzzqqqq: 1\n"),
|
|
37
|
+
(err) => err.message.includes("zzzzqqqq")
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("类型错误逐条收集、一次报完(不 fail-fast)", () => {
|
|
42
|
+
try {
|
|
43
|
+
loadPlan("exclude: not-an-array\nmax_files_per_page: zero\npages:\n - path: \"\"\n");
|
|
44
|
+
assert.fail("应该抛 PlanInvalidError");
|
|
45
|
+
} catch (err) {
|
|
46
|
+
assert.ok(err instanceof PlanInvalidError);
|
|
47
|
+
assert.ok(err.issues.length >= 3, `至少三条 issue,实际 ${err.issues.length}`);
|
|
48
|
+
assert.ok(err.issues.some((i) => i.includes("exclude")));
|
|
49
|
+
assert.ok(err.issues.some((i) => i.includes("max_files_per_page")));
|
|
50
|
+
assert.ok(err.issues.some((i) => i.includes("pages[0]")));
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("YAML 语法错误也走同一条拒绝路径", () => {
|
|
55
|
+
assert.throws(() => loadPlan("a: [1, 2\nb: }\n"), PlanInvalidError);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("pages 白名单形状合法时被接受,模板/笔记被保留", () => {
|
|
59
|
+
const { plan } = loadPlan(
|
|
60
|
+
"pages:\n - path: src\n title: 源码主区\n template: arch\n notes: 关注导出面\n" +
|
|
61
|
+
"templates:\n arch: |\n 自定义正文行\n"
|
|
62
|
+
);
|
|
63
|
+
assert.deepEqual(plan.pages, [{ path: "src", title: "源码主区", template: "arch", notes: "关注导出面" }]);
|
|
64
|
+
assert.equal(plan.templates.arch, "自定义正文行\n");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("pages 重复 path / 未知页级键 / 非 mapping 都被点名", () => {
|
|
68
|
+
try {
|
|
69
|
+
loadPlan("pages:\n - path: src\n - path: src\n - bogus: 1\n - 42\n");
|
|
70
|
+
assert.fail("应该抛");
|
|
71
|
+
} catch (err) {
|
|
72
|
+
assert.ok(err.issues.some((i) => i.includes("重复")));
|
|
73
|
+
assert.ok(err.issues.some((i) => i.includes("bogus")));
|
|
74
|
+
assert.ok(err.issues.some((i) => i.includes("pages[3]")));
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("指纹随内容变化(plan.yaml 改了就要重划边界)", () => {
|
|
79
|
+
assert.notEqual(planFingerprint("a: 1\n"), planFingerprint("a: 2\n"));
|
|
80
|
+
});
|