@modusensus/dsh-mneme 0.10.1 → 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.
- package/README.md +10 -3
- package/bin/dsh-mneme-choro.mjs +254 -0
- package/docs/CONFIGURATION.md +8 -1
- package/lib/api-standalone.js +17 -21
- package/lib/api.js +247 -11
- package/lib/bootstrap-http.js +105 -0
- package/lib/bootstrap.js +5 -1
- package/lib/choro-command.js +104 -0
- package/lib/client.js +1028 -102
- package/lib/config-keys.js +144 -0
- package/lib/config.js +28 -0
- package/lib/index.js +50 -4
- package/lib/inject.js +15 -3
- package/lib/lang.js +54 -0
- package/lib/quality-filter.js +48 -12
- package/lib/service.js +3 -0
- package/lib/settings.js +8 -1
- package/lib/store.js +502 -0
- package/lib/tools.js +127 -1
- 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 +220 -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/src/api-standalone.js +17 -21
- package/src/api.js +247 -11
- 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 +28 -0
- package/src/index.js +50 -4
- package/src/inject.js +15 -3
- package/src/lang.js +54 -0
- package/src/quality-filter.js +48 -12
- package/src/service.js +3 -0
- package/src/settings.js +8 -1
- package/src/store.js +502 -0
- package/src/tools.js +127 -1
- 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 +220 -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 +7 -1
- package/test/bootstrap.test.js +8 -0
- package/test/choro-command.test.js +218 -0
- package/test/client.test.js +655 -19
- package/test/config-keys-routes.test.js +233 -0
- package/test/contrast.test.js +14 -7
- package/test/inject.test.js +51 -0
- package/test/llm-audit.test.js +29 -1
- 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/wiki-api.test.js +153 -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 +187 -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 +121 -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
|
+
});
|
|
@@ -0,0 +1,187 @@
|
|
|
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 } 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
|
+
test("listRepos / resolveRepo:单仓自动收敛,多仓歧义要显式给键", () => {
|
|
50
|
+
const { root, store, q } = setup();
|
|
51
|
+
try {
|
|
52
|
+
const repos = q.listRepos();
|
|
53
|
+
assert.equal(repos.length, 1);
|
|
54
|
+
assert.equal(repos[0].module_count, 2, "src 与 root 两个模块(1 条跨模块边不触发合并)");
|
|
55
|
+
assert.equal(repos[0].page_count, 2);
|
|
56
|
+
const auto = q.resolveRepo(undefined);
|
|
57
|
+
assert.equal(auto.id, repos[0].id, "只有一个仓库时不带键也能解析");
|
|
58
|
+
assert.ok(q.resolveRepo("qfix").id, "按显示名解析");
|
|
59
|
+
assert.equal(q.resolveRepo("no-such-repo"), null);
|
|
60
|
+
// 造第二个仓库让解析变歧义
|
|
61
|
+
const root2 = makeFixture();
|
|
62
|
+
try {
|
|
63
|
+
runChoroT0({ repoRoot: root2, store, apply: true, now: FIXED_NOW });
|
|
64
|
+
const amb = q.resolveRepo(undefined);
|
|
65
|
+
assert.ok(amb.ambiguous, "多仓无键 → 歧义清单而不是猜一个");
|
|
66
|
+
assert.equal(amb.ambiguous.length, 2);
|
|
67
|
+
} finally {
|
|
68
|
+
rmTempBestEffort(root2);
|
|
69
|
+
}
|
|
70
|
+
} finally {
|
|
71
|
+
store.close();
|
|
72
|
+
rmTempBestEffort(root);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("moduleMap:模块、页面状态与跨模块边同源返回", () => {
|
|
77
|
+
const { root, store, q } = setup();
|
|
78
|
+
try {
|
|
79
|
+
const repo = q.resolveRepo(undefined);
|
|
80
|
+
const { modules, edges } = q.moduleMap(repo.id);
|
|
81
|
+
const slugs = modules.map((m) => m.slug).sort();
|
|
82
|
+
assert.deepEqual(slugs, ["root", "src"]);
|
|
83
|
+
const src = modules.find((m) => m.slug === "src");
|
|
84
|
+
assert.equal(src.page_status, "skeleton");
|
|
85
|
+
// fixture 的引用全在 src 模块内部(a→b→b2),left-pad 是外部依赖,root 只有
|
|
86
|
+
// package.json 没有代码——跨模块边应为空集。跨模块聚erge本身由 wiki-store 的
|
|
87
|
+
// 整仓替换用例与 t0 的 moduleEdges 排序覆盖。
|
|
88
|
+
assert.deepEqual(edges, []);
|
|
89
|
+
} finally {
|
|
90
|
+
store.close();
|
|
91
|
+
rmTempBestEffort(root);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("readPage:正文来自磁盘真源;盘上缺页如实报 page-missing;未知 slug 返回 null", () => {
|
|
96
|
+
const { root, store, q } = setup();
|
|
97
|
+
try {
|
|
98
|
+
const repo = q.resolveRepo(undefined);
|
|
99
|
+
const page = q.readPage(repo.id, "src");
|
|
100
|
+
assert.equal(page.slug, "src");
|
|
101
|
+
assert.ok(page.content.includes("## 文件清单"), "正文是骨架页");
|
|
102
|
+
assert.equal(page.content.split("\n").length > 10, true);
|
|
103
|
+
assert.equal(q.readPage(repo.id, "../../etc/passwd"), null, "未知 slug 一律 null(路径零用户输入)");
|
|
104
|
+
// 盘上删页:索引还在、读侧如实报缺
|
|
105
|
+
unlinkSync(join(root, ".chorography", "modules", "src.md"));
|
|
106
|
+
const missing = q.readPage(repo.id, "src");
|
|
107
|
+
assert.equal(missing.error, "page-missing");
|
|
108
|
+
} finally {
|
|
109
|
+
store.close();
|
|
110
|
+
rmTempBestEffort(root);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("search:按节返回命中与行号,空 q 返回空数组,module 过滤生效", () => {
|
|
115
|
+
const { root, store, q } = setup();
|
|
116
|
+
try {
|
|
117
|
+
const repo = q.resolveRepo(undefined);
|
|
118
|
+
// 骨架页里必有「文件清单」节标题与 b2.js 文件名
|
|
119
|
+
const hits = q.search(repo.id, "文件清单");
|
|
120
|
+
assert.ok(hits.length >= 2, "两页都有文件清单节");
|
|
121
|
+
assert.ok(hits.every((h) => h.line >= 1 && typeof h.section === "string"));
|
|
122
|
+
const byFile = q.search(repo.id, "b2.js");
|
|
123
|
+
// 命中两处是契约而非巧合:frontmatter 的 sourceFiles(节=页首)+ 正文「文件
|
|
124
|
+
// 清单」表。分节让调用方能区分这两层。
|
|
125
|
+
assert.equal(byFile.length, 2);
|
|
126
|
+
assert.ok(byFile.every((h) => h.slug === "src"), "b2.js 只属于 src 页");
|
|
127
|
+
assert.equal(byFile.filter((h) => h.section.startsWith("文件清单")).length, 1, "节名带数量后缀(文件清单(N 个)),按前缀匹配");
|
|
128
|
+
assert.deepEqual(q.search(repo.id, " "), [], "空 q 不报错返回空");
|
|
129
|
+
const inRoot = q.search(repo.id, "package.json", { moduleSlug: "root" });
|
|
130
|
+
assert.equal(inRoot.length, 2, "与 b2.js 同款两层命中:frontmatter(页首)+ 文件清单表");
|
|
131
|
+
assert.equal(inRoot[0].slug, "root");
|
|
132
|
+
assert.deepEqual(q.search(repo.id, "package.json", { moduleSlug: "src" }), [], "模块过滤真生效");
|
|
133
|
+
} finally {
|
|
134
|
+
store.close();
|
|
135
|
+
rmTempBestEffort(root);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
test("repoByCwd:祖先前缀命中、最长 root 胜出、cwd 不存在返回 null", () => {
|
|
139
|
+
const { root, store, q } = setup();
|
|
140
|
+
let nested = null;
|
|
141
|
+
try {
|
|
142
|
+
// 精确根目录
|
|
143
|
+
const exact = q.repoByCwd(root);
|
|
144
|
+
assert.ok(exact, "仓库根命中");
|
|
145
|
+
// 子目录也要命中——真实会话的 cwd 常常是 D:/repo/src 这种
|
|
146
|
+
const sub = q.repoByCwd(join(root, "src"));
|
|
147
|
+
assert.equal(sub.id, exact.id, "子目录按祖先前缀命中同一个仓库");
|
|
148
|
+
assert.equal(q.repoByCwd(join(root, "no-such-dir")), null, "cwd 不存在 → null(不是抛错,注入在热路径上)");
|
|
149
|
+
assert.equal(q.repoByCwd(""), null);
|
|
150
|
+
assert.equal(q.repoByCwd(null), null);
|
|
151
|
+
// 嵌套仓库:外层先登记,内层后登记 → 内层会话必须被内层抢走(最长前缀)
|
|
152
|
+
nested = makeFixture();
|
|
153
|
+
runChoroT0({ repoRoot: nested, store, apply: true, now: FIXED_NOW });
|
|
154
|
+
const inner = q.repoByCwd(join(nested, "src"));
|
|
155
|
+
assert.equal(inner.root_path, nested, "嵌套时取最深那层,否则内层会话被外层仓库抢走");
|
|
156
|
+
assert.equal(q.repoByCwd(root).root_path, root, "外层 cwd 仍落外层");
|
|
157
|
+
} finally {
|
|
158
|
+
store.close();
|
|
159
|
+
if (nested) rmTempBestEffort(nested);
|
|
160
|
+
rmTempBestEffort(root);
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("outline:模块名 + 文件数 + 页状态 + 页相对路径,不读正文", () => {
|
|
165
|
+
const { root, store, q } = setup();
|
|
166
|
+
try {
|
|
167
|
+
const repo = q.resolveRepo(undefined);
|
|
168
|
+
const out = q.outline(repo.id);
|
|
169
|
+
assert.equal(out.repo.id, repo.id);
|
|
170
|
+
assert.equal(out.repo.name, "qfix");
|
|
171
|
+
assert.equal(out.modules.length, 2);
|
|
172
|
+
const srcMod = out.modules.find((m) => m.slug === "src");
|
|
173
|
+
assert.equal(srcMod.status, "skeleton");
|
|
174
|
+
assert.equal(srcMod.relPath, ".chorography/modules/src.md");
|
|
175
|
+
assert.ok(srcMod.fileCount >= 3);
|
|
176
|
+
assert.equal(srcMod.parentSlug, null);
|
|
177
|
+
assert.ok(out.generatedAt, "生成时间由页行的 generated_at 汇总");
|
|
178
|
+
// 「只注标题级」的机制锁:提纲里不许出现页正文片段(骨架页的节标题/说明行)
|
|
179
|
+
const serialized = JSON.stringify(out);
|
|
180
|
+
assert.equal(serialized.includes("## 文件清单"), false, "提纲不含正文节标题");
|
|
181
|
+
assert.equal(serialized.includes("确定性骨架"), false, "提纲不含页内说明行");
|
|
182
|
+
assert.equal(q.outline(9999), null, "未知 repo → null");
|
|
183
|
+
} finally {
|
|
184
|
+
store.close();
|
|
185
|
+
rmTempBestEffort(root);
|
|
186
|
+
}
|
|
187
|
+
});
|