@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.
Files changed (75) hide show
  1. package/README.md +10 -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 +247 -11
  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 +1028 -102
  10. package/lib/config-keys.js +144 -0
  11. package/lib/config.js +28 -0
  12. package/lib/index.js +50 -4
  13. package/lib/inject.js +15 -3
  14. package/lib/lang.js +54 -0
  15. package/lib/quality-filter.js +48 -12
  16. package/lib/service.js +3 -0
  17. package/lib/settings.js +8 -1
  18. package/lib/store.js +502 -0
  19. package/lib/tools.js +127 -1
  20. package/lib/wiki/inject.js +164 -0
  21. package/lib/wiki/partition.js +200 -0
  22. package/lib/wiki/plan.js +214 -0
  23. package/lib/wiki/queries.js +220 -0
  24. package/lib/wiki/render.js +187 -0
  25. package/lib/wiki/scan.js +371 -0
  26. package/lib/wiki/t0.js +463 -0
  27. package/lib/wiki/t1.js +496 -0
  28. package/lib/write-admission.js +5 -3
  29. package/package.json +4 -2
  30. package/src/api-standalone.js +17 -21
  31. package/src/api.js +247 -11
  32. package/src/bootstrap-http.js +105 -0
  33. package/src/bootstrap.js +5 -1
  34. package/src/choro-command.js +104 -0
  35. package/src/config-keys.js +144 -0
  36. package/src/config.js +28 -0
  37. package/src/index.js +50 -4
  38. package/src/inject.js +15 -3
  39. package/src/lang.js +54 -0
  40. package/src/quality-filter.js +48 -12
  41. package/src/service.js +3 -0
  42. package/src/settings.js +8 -1
  43. package/src/store.js +502 -0
  44. package/src/tools.js +127 -1
  45. package/src/wiki/inject.js +164 -0
  46. package/src/wiki/partition.js +200 -0
  47. package/src/wiki/plan.js +214 -0
  48. package/src/wiki/queries.js +220 -0
  49. package/src/wiki/render.js +187 -0
  50. package/src/wiki/scan.js +371 -0
  51. package/src/wiki/t0.js +463 -0
  52. package/src/wiki/t1.js +496 -0
  53. package/src/write-admission.js +5 -3
  54. package/test/api-routes-gaps.test.js +131 -1
  55. package/test/api.test.js +7 -1
  56. package/test/bootstrap.test.js +8 -0
  57. package/test/choro-command.test.js +218 -0
  58. package/test/client.test.js +655 -19
  59. package/test/config-keys-routes.test.js +233 -0
  60. package/test/contrast.test.js +14 -7
  61. package/test/inject.test.js +51 -0
  62. package/test/llm-audit.test.js +29 -1
  63. package/test/quality-backfill.test.js +290 -0
  64. package/test/quality-filter.test.js +113 -4
  65. package/test/service.test.js +1 -1
  66. package/test/wiki-api.test.js +153 -0
  67. package/test/wiki-inject.test.js +265 -0
  68. package/test/wiki-partition.test.js +112 -0
  69. package/test/wiki-plan.test.js +80 -0
  70. package/test/wiki-queries.test.js +187 -0
  71. package/test/wiki-scan.test.js +208 -0
  72. package/test/wiki-store.test.js +190 -0
  73. package/test/wiki-t0.test.js +380 -0
  74. package/test/wiki-t1.test.js +235 -0
  75. package/test/wiki-tools.test.js +121 -0
@@ -0,0 +1,214 @@
1
+ // dsh-mneme/src/wiki/plan.js
2
+ // chorography 的 plan.yaml 正面控制(spec §4 / 决策 D7):白黑名单、每页模板、
3
+ // 作图笔记。这是用户手写的文件,机器只读不写。
4
+ //
5
+ // 为什么校验是「未知键整轮不跑」而不是「忽略并继续」(spec 降级表第一行):拼错
6
+ // 一个键名(include 写成 includes)等于那条控制整段失效,而用户以为它在生效——
7
+ // 静默忽略的代价是生成结果和用户意图悄悄分叉,正是 D7 要防的事。整轮拒绝 + 报出
8
+ // 键名与允许列表,把拼错变成一次可修正的显式失败。
9
+ //
10
+ // 校验收全部 issue 后一次报出,不 fail-fast:人改配置通常一次错多处,逐个试错
11
+ // 只会多跑几轮生成。
12
+ //
13
+ // 依赖 `yaml`(实现计划 P1):plan.yaml 是用户配置,手写子集解析器会在注释 /
14
+ // 锚点 / 多行字符串上静默错解,错解配置比拒绝配置更伤。
15
+ import { readFileSync, existsSync } from "node:fs";
16
+ import { join } from "node:path";
17
+ import { createHash } from "node:crypto";
18
+ import { parseDocument } from "yaml";
19
+
20
+ export const PLAN_ALLOWED_KEYS = Object.freeze([
21
+ "include", "exclude", "pages", "templates", "max_files_per_page",
22
+ ]);
23
+ export const PLAN_PAGE_ALLOWED_KEYS = Object.freeze(["path", "title", "template", "notes"]);
24
+ export const DEFAULT_MAX_FILES_PER_PAGE = 40;
25
+
26
+ // plan.yaml 不存在时的启发式默认(spec §6 步 1):全量 include、零 exclude 追加
27
+ // (目录级排除由 scan.js 的默认排除表承担:node_modules/构建产物 + 一切点开头目录,
28
+ // 见 DEFAULT_EXCLUDE_DIRS 上方注释——所以空 plan 也不是「什么都读」)、
29
+ // 无页面白名单(划分结果全建页)。
30
+ export function defaultPlan() {
31
+ return {
32
+ include: null,
33
+ exclude: [],
34
+ pages: null,
35
+ templates: {},
36
+ maxFilesPerPage: DEFAULT_MAX_FILES_PER_PAGE,
37
+ };
38
+ }
39
+
40
+ /** plan.yaml 的内容指纹(无文件时对空串取 hash,保证「没带 plan」也是一个可记账状态)。 */
41
+ export function planFingerprint(rawText) {
42
+ return createHash("sha256").update(rawText ?? "", "utf8").digest("hex");
43
+ }
44
+
45
+ /** 校验拒绝:issues 是人可读的中文清单,CLI 原样透出(spec 降级表:报出键名与允许列表)。 */
46
+ export class PlanInvalidError extends Error {
47
+ constructor(issues, { planPath } = {}) {
48
+ const lines = issues.map((it) => ` - ${it}`);
49
+ super(
50
+ `plan.yaml 校验不通过,本轮未运行${planPath ? `(${planPath})` : ""}:\n${lines.join("\n")}\n` +
51
+ `允许的顶层键:${PLAN_ALLOWED_KEYS.join(" / ")}`
52
+ );
53
+ this.name = "PlanInvalidError";
54
+ this.issues = issues;
55
+ }
56
+ }
57
+
58
+ function typeOf(value) {
59
+ if (value === null) return "null";
60
+ if (Array.isArray(value)) return "array";
61
+ return typeof value;
62
+ }
63
+
64
+ function expectType(value, want, label, issues) {
65
+ const got = typeOf(value);
66
+ if (got !== want) {
67
+ issues.push(`${label} 需要 ${want === "array" ? "数组" : want === "string" ? "字符串" : want},实际是 ${got}`);
68
+ return false;
69
+ }
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * 解析并校验 plan.yaml。rawText 为空(文件不存在)时返回默认启发式。
75
+ * 任何校验失败抛 PlanInvalidError(收集全部 issue)。
76
+ */
77
+ export function loadPlan(rawText, { planPath } = {}) {
78
+ if (rawText == null || String(rawText).trim() === "") return { plan: defaultPlan(), fingerprint: planFingerprint("") };
79
+
80
+ const doc = parseDocument(rawText);
81
+ const issues = [];
82
+ if (doc.errors.length > 0) {
83
+ for (const err of doc.errors) issues.push(`YAML 语法错误:${String(err.message ?? err)}`);
84
+ throw new PlanInvalidError(issues, { planPath });
85
+ }
86
+ const root = doc.toJS({ maxAliasCount: 200 });
87
+
88
+ if (root === null || root === undefined) {
89
+ throw new PlanInvalidError(["文件是空的:要么删掉 plan.yaml 走默认启发式,要么写一条配置"], { planPath });
90
+ }
91
+ if (typeof root !== "object" || Array.isArray(root)) {
92
+ throw new PlanInvalidError([`顶层必须是键值映射(mapping),实际是 ${Array.isArray(root) ? "数组" : typeof root}`], { planPath });
93
+ }
94
+
95
+ const known = new Set(PLAN_ALLOWED_KEYS);
96
+ for (const key of Object.keys(root)) {
97
+ if (!known.has(key)) {
98
+ const near = nearestKey(key);
99
+ issues.push(
100
+ `未知键 "${key}"(本轮拒绝运行:拼错的键等于失效的控制,不能静默忽略)` +
101
+ (near ? `;如果你要写的是 "${near}",请改正键名` : "")
102
+ );
103
+ }
104
+ }
105
+
106
+ const plan = defaultPlan();
107
+
108
+ if (root.include !== undefined) {
109
+ if (expectType(root.include, "array", "include", issues)) plan.include = root.include.map(String);
110
+ }
111
+ if (root.exclude !== undefined) {
112
+ if (expectType(root.exclude, "array", "exclude", issues)) plan.exclude = root.exclude.map(String);
113
+ }
114
+ if (root.max_files_per_page !== undefined) {
115
+ const v = root.max_files_per_page;
116
+ if (!Number.isInteger(v) || v <= 0) {
117
+ issues.push(`max_files_per_page 需要正整数,实际是 ${JSON.stringify(v)}`);
118
+ } else {
119
+ plan.maxFilesPerPage = v;
120
+ }
121
+ }
122
+ if (root.templates !== undefined) {
123
+ if (expectType(root.templates, "object", "templates", issues) && !Array.isArray(root.templates)) {
124
+ for (const [name, body] of Object.entries(root.templates)) {
125
+ if (typeof body !== "string") {
126
+ issues.push(`templates.${name} 的值需要字符串(模板正文),实际是 ${typeOf(body)}`);
127
+ } else {
128
+ plan.templates[name] = body;
129
+ }
130
+ }
131
+ } else if (Array.isArray(root.templates)) {
132
+ issues.push("templates 需要键值映射(模板名 → 正文),实际是数组");
133
+ }
134
+ }
135
+ if (root.pages !== undefined) {
136
+ if (expectType(root.pages, "array", "pages", issues)) {
137
+ const seen = new Set();
138
+ plan.pages = [];
139
+ root.pages.forEach((entry, i) => {
140
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
141
+ issues.push(`pages[${i}] 需要键值映射(path/title/template/notes),实际是 ${typeOf(entry)}`);
142
+ return;
143
+ }
144
+ for (const key of Object.keys(entry)) {
145
+ if (!PLAN_PAGE_ALLOWED_KEYS.includes(key)) {
146
+ issues.push(`pages[${i}] 有未知键 "${key}";页级允许:${PLAN_PAGE_ALLOWED_KEYS.join(" / ")}`);
147
+ }
148
+ }
149
+ const path = typeof entry.path === "string" ? entry.path.trim() : "";
150
+ if (path === "") {
151
+ issues.push(`pages[${i}] 缺 path(页面标识,对应模块 slug)`);
152
+ return;
153
+ }
154
+ if (seen.has(path)) issues.push(`pages 里 path "${path}" 重复出现`);
155
+ seen.add(path);
156
+ if (entry.title !== undefined && typeof entry.title !== "string") {
157
+ issues.push(`pages["${path}"].title 需要字符串,实际是 ${typeOf(entry.title)}`);
158
+ }
159
+ if (entry.template !== undefined && typeof entry.template !== "string") {
160
+ issues.push(`pages["${path}"].template 需要字符串(模板名),实际是 ${typeOf(entry.template)}`);
161
+ }
162
+ if (entry.notes !== undefined && typeof entry.notes !== "string") {
163
+ issues.push(`pages["${path}"].notes 需要字符串,实际是 ${typeOf(entry.notes)}`);
164
+ }
165
+ plan.pages.push({
166
+ path,
167
+ title: typeof entry.title === "string" ? entry.title : undefined,
168
+ template: typeof entry.template === "string" ? entry.template : undefined,
169
+ notes: typeof entry.notes === "string" ? entry.notes : undefined,
170
+ });
171
+ });
172
+ }
173
+ }
174
+
175
+ if (issues.length > 0) throw new PlanInvalidError(issues, { planPath });
176
+ return { plan, fingerprint: planFingerprint(rawText) };
177
+ }
178
+
179
+ /** 从磁盘读 plan.yaml。<repoRoot>/.chorography/plan.yaml 是唯一位置(spec §4)。 */
180
+ export function loadPlanFromRepo(repoRoot) {
181
+ const planPath = join(repoRoot, ".chorography", "plan.yaml");
182
+ if (!existsSync(planPath)) return { plan: defaultPlan(), fingerprint: planFingerprint(""), planPath: null, raw: "" };
183
+ const raw = readFileSync(planPath, "utf8");
184
+ return { ...loadPlan(raw, { planPath }), planPath, raw };
185
+ }
186
+
187
+ // 编辑距离只用来给「未知键」提示一个最近候选;实现取经典 DP,规模只有几个键,
188
+ // 不值得引库。距离不够近(超过键长一半或大于 2)就不猜,猜错提示比没有提示更吵。
189
+ function nearestKey(key) {
190
+ let best = null;
191
+ let bestDist = Infinity;
192
+ for (const candidate of PLAN_ALLOWED_KEYS) {
193
+ const dist = editDistance(key.toLowerCase(), candidate.toLowerCase());
194
+ if (dist < bestDist) {
195
+ bestDist = dist;
196
+ best = candidate;
197
+ }
198
+ }
199
+ return bestDist <= Math.max(2, Math.floor(key.length / 2)) ? best : null;
200
+ }
201
+
202
+ function editDistance(a, b) {
203
+ const prev = new Array(b.length + 1).fill(0).map((_, i) => i);
204
+ for (let i = 1; i <= a.length; i++) {
205
+ let carry = prev[0];
206
+ prev[0] = i;
207
+ for (let j = 1; j <= b.length; j++) {
208
+ const tmp = prev[j];
209
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, carry + (a[i - 1] === b[j - 1] ? 0 : 1));
210
+ carry = tmp;
211
+ }
212
+ }
213
+ return prev[b.length];
214
+ }
@@ -0,0 +1,220 @@
1
+ // dsh-mneme/src/wiki/queries.js
2
+ // chorography 的读侧(M1):仓库列表 / 模块图 / 页面正文 / 关键词检索。
3
+ // 只读,零写路径——写入口只有 M0 的 t0.js(红线 3:模型侧一个写工具都不注册)。
4
+ //
5
+ // 真源纪律(spec §4 / P12):页面正文永远读仓库磁盘的 MD(root_path + DB 里的
6
+ // rel_path),库里的表只是索引与状态账。slug 只从 DB 白名单解析,路径拼装零用户
7
+ // 输入——面板与工具传进来的 slug 查不到模块行就返回 null,绝不用它拼路径。
8
+ import { readFileSync, existsSync, realpathSync } from "node:fs";
9
+ import { join, resolve, sep } from "node:path";
10
+
11
+ export function createChoroQueries({ store }) {
12
+ const db = store.db;
13
+
14
+ /** 已登记仓库(含模块/页面计数,面板仓库选择器与工具的 repo 解析共用)。 */
15
+ function listRepos() {
16
+ const rows = db.prepare(
17
+ `SELECT r.id, r.root_path, r.name, r.plan_fingerprint, r.git_baseline, r.updated_at,
18
+ (SELECT COUNT(*) FROM choro_modules m WHERE m.repo_id = r.id) AS module_count,
19
+ (SELECT COUNT(*) FROM choro_pages p JOIN choro_modules m2 ON p.module_id = m2.id
20
+ WHERE m2.repo_id = r.id) AS page_count
21
+ FROM choro_repos r ORDER BY r.id`
22
+ ).all();
23
+ return rows;
24
+ }
25
+
26
+ /** repo 解析:数字 id 或显示名(root_path 是本机私有路径,不作为对外键)。 */
27
+ function resolveRepo(ref) {
28
+ if (ref === undefined || ref === null || ref === "") {
29
+ const rows = listRepos();
30
+ if (rows.length === 1) return rows[0];
31
+ if (rows.length === 0) return null;
32
+ return { ambiguous: rows.map((r) => ({ id: r.id, name: r.name })) };
33
+ }
34
+ const byId = db.prepare("SELECT * FROM choro_repos WHERE id = ?").get(Number(ref));
35
+ if (byId) return byId;
36
+ const byName = db.prepare("SELECT * FROM choro_repos WHERE name = ?").get(String(ref));
37
+ return byName ?? null;
38
+ }
39
+
40
+ /** 模块表 + 跨模块边(choro_module_map 与面板左侧树共用同一份数据)。 */
41
+ function moduleMap(repoId) {
42
+ const modules = db.prepare(
43
+ `SELECT m.id, m.slug, m.title, m.parent_id, m.file_count, m.fingerprint, m.updated_at,
44
+ p.status AS page_status, p.rel_path AS page_rel_path
45
+ FROM choro_modules m
46
+ LEFT JOIN choro_pages p ON p.module_id = m.id
47
+ WHERE m.repo_id = ? ORDER BY m.slug`
48
+ ).all(repoId);
49
+ const edges = db.prepare(
50
+ `SELECT from_slug, to_slug, weight FROM choro_edges WHERE repo_id = ?
51
+ ORDER BY from_slug, to_slug`
52
+ ).all(repoId);
53
+ return { modules, edges };
54
+ }
55
+
56
+ /**
57
+ * 读一页正文。slug 必须命中 DB 的模块行(P12:路径零用户输入);磁盘文件被手删
58
+ * 时返回 { error: "page-missing" }(索引还没重算,读侧如实报缺,不伪造内容)。
59
+ */
60
+ function readPage(repoId, slug) {
61
+ const row = db.prepare(
62
+ `SELECT m.slug, m.title, m.file_count, m.parent_id, p.rel_path, p.status,
63
+ p.git_baseline, p.template_version, p.generated_at, p.human_block_count
64
+ FROM choro_modules m JOIN choro_pages p ON p.module_id = m.id
65
+ WHERE m.repo_id = ? AND m.slug = ?`
66
+ ).get(repoId, String(slug));
67
+ if (!row) return null;
68
+ const repo = db.prepare("SELECT root_path, name FROM choro_repos WHERE id = ?").get(repoId);
69
+ const abs = join(repo.root_path, row.rel_path);
70
+ if (!existsSync(abs)) return { error: "page-missing", slug: row.slug, relPath: row.rel_path };
71
+ return {
72
+ slug: row.slug,
73
+ title: row.title,
74
+ fileCount: row.file_count,
75
+ status: row.status,
76
+ gitBaseline: row.git_baseline,
77
+ templateVersion: row.template_version,
78
+ generatedAt: row.generated_at,
79
+ humanBlockCount: row.human_block_count,
80
+ repoName: repo.name,
81
+ relPath: row.rel_path,
82
+ content: readFileSync(abs, "utf8"),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * 关键词检索(P10):按 `## ` 分节做大小写不敏感子串匹配,命中返回页 slug、
88
+ * 节标题、起始行号与摘录。只扫 DB 里登记过的页面文件(不是全目录 glob——
89
+ * 划分变更后未重算的孤儿文件不该被搜出来)。q 为空返回空数组不报错。
90
+ */
91
+ function search(repoId, q, { moduleSlug = null, limit = 20 } = {}) {
92
+ const needle = String(q ?? "").trim().toLowerCase();
93
+ if (needle === "") return [];
94
+ const cap = Math.max(1, Math.min(100, Number(limit) || 20));
95
+ const pages = db.prepare(
96
+ `SELECT m.slug, m.title AS module_title, p.rel_path
97
+ FROM choro_modules m JOIN choro_pages p ON p.module_id = m.id
98
+ WHERE m.repo_id = ? AND (? IS NULL OR m.slug = ?)
99
+ ORDER BY m.slug`
100
+ ).all(repoId, moduleSlug, moduleSlug);
101
+ const repo = db.prepare("SELECT root_path FROM choro_repos WHERE id = ?").get(repoId);
102
+ const hits = [];
103
+ for (const page of pages) {
104
+ const abs = join(repo.root_path, page.rel_path);
105
+ if (!existsSync(abs)) continue;
106
+ let text;
107
+ try {
108
+ text = readFileSync(abs, "utf8");
109
+ } catch {
110
+ continue;
111
+ }
112
+ const lines = text.split("\n");
113
+ // 分节:每个 `## ` 标题开启一节,节内所有行都属于该节;文件头(frontmatter
114
+ // 之后到第一个 `## `)算 "(页首)" 一节,保证对 title/摘要的命中也有落点。
115
+ let section = "(页首)";
116
+ let sectionStart = 1;
117
+ for (let i = 0; i < lines.length; i++) {
118
+ const line = lines[i];
119
+ if (i > 0 && /^## /.test(line)) {
120
+ section = line.replace(/^## /, "").trim();
121
+ sectionStart = i + 1;
122
+ }
123
+ if (line.toLowerCase().includes(needle)) {
124
+ hits.push({
125
+ slug: page.slug,
126
+ moduleTitle: page.module_title,
127
+ section,
128
+ line: i + 1,
129
+ sectionStart,
130
+ excerpt: line.trim().slice(0, 200),
131
+ });
132
+ if (hits.length >= cap) return hits;
133
+ }
134
+ }
135
+ }
136
+ return hits;
137
+ }
138
+
139
+ /**
140
+ * 按会话工作目录找已登记仓库(M3 开局注入的唯一入口)。
141
+ *
142
+ * 三个刻意的取舍:
143
+ * 1. **祖先前缀匹配**而不是精确相等——真实会话的 cwd 常常是仓库子目录
144
+ * (`D:/repo/src`),精确相等会让绝大部分会话匹配不到,等于逼用户
145
+ * 「必须在仓库根开会话」这条暗规则。
146
+ * 2. 两侧都做 realpath 归一:Windows 上盘符大小写、8.3 短名、符号链接
147
+ * 会让同一个目录写出不同字符串;t0 落库时存的就是 realpath。
148
+ * 3. **最长前缀胜出**——仓库嵌套(子模块、`.worktrees/`)时取最深那层,
149
+ * 否则内层会话会被外层仓库抢走。
150
+ * cwd 不存在或不是目录时返回 null(不是抛错):注入在热路径上,失败只
151
+ * 意味着「本帧不注入」。
152
+ */
153
+ function repoByCwd(cwd) {
154
+ const raw = String(cwd ?? "").trim();
155
+ if (raw === "") return null;
156
+ let normalized;
157
+ try {
158
+ normalized = realpathSync(resolve(raw));
159
+ } catch {
160
+ return null;
161
+ }
162
+ const cmp = (p) => (process.platform === "win32" ? p.toLowerCase() : p);
163
+ const target = cmp(normalized);
164
+ const rows = db.prepare("SELECT * FROM choro_repos ORDER BY id").all();
165
+ let best = null;
166
+ for (const row of rows) {
167
+ const root = cmp(String(row.root_path ?? ""));
168
+ if (root === "") continue;
169
+ const hit = target === root || target.startsWith(root.endsWith(sep) ? root : root + sep);
170
+ if (!hit) continue;
171
+ if (!best || root.length > cmp(String(best.root_path)).length) best = row;
172
+ }
173
+ return best ?? null;
174
+ }
175
+
176
+ /**
177
+ * 注入与 /choro 共用的最小提纲(M3):模块名 + 文件数 + 页状态 + 页相对路径。
178
+ * **不读页正文**——spec §7 要求「只注标题级不注正文」,正文走 readPage。
179
+ * parentSlug 单独解出来给渲染层做层级缩进(DB 里存的是 parent_id)。
180
+ */
181
+ function outline(repoId) {
182
+ const repo = db.prepare(
183
+ "SELECT id, root_path, name, git_baseline, plan_fingerprint, updated_at FROM choro_repos WHERE id = ?"
184
+ ).get(repoId);
185
+ if (!repo) return null;
186
+ const rows = db.prepare(
187
+ `SELECT m.id, m.slug, m.title, m.parent_id, m.file_count,
188
+ p.status AS page_status, p.rel_path AS page_rel_path, p.generated_at
189
+ FROM choro_modules m
190
+ LEFT JOIN choro_pages p ON p.module_id = m.id
191
+ WHERE m.repo_id = ? ORDER BY m.slug`
192
+ ).all(repoId);
193
+ const slugById = new Map(rows.map((r) => [r.id, r.slug]));
194
+ let generatedAt = null;
195
+ const modules = rows.map((r) => {
196
+ if (r.generated_at && (generatedAt === null || r.generated_at > generatedAt)) generatedAt = r.generated_at;
197
+ return {
198
+ slug: r.slug,
199
+ title: r.title,
200
+ fileCount: r.file_count,
201
+ parentSlug: r.parent_id === null || r.parent_id === undefined ? null : slugById.get(r.parent_id) ?? null,
202
+ status: r.page_status ?? null,
203
+ relPath: r.page_rel_path ?? null,
204
+ };
205
+ });
206
+ return {
207
+ repo: {
208
+ id: repo.id,
209
+ name: repo.name,
210
+ rootPath: repo.root_path,
211
+ gitBaseline: repo.git_baseline,
212
+ updatedAt: repo.updated_at,
213
+ },
214
+ generatedAt: generatedAt ?? repo.updated_at ?? null,
215
+ modules,
216
+ };
217
+ }
218
+
219
+ return { listRepos, resolveRepo, moduleMap, readPage, search, repoByCwd, outline };
220
+ }
@@ -0,0 +1,187 @@
1
+ // dsh-mneme/src/wiki/render.js
2
+ // T0 骨架页与 index.md 的确定性渲染(spec §6 步 6),以及混合所有权的保留区机制
3
+ // (spec §4:`<!-- CHORO:HUMAN -->` 标记块重生成时逐字节保留)。
4
+ //
5
+ // 确定性纪律(spec §9 可重建不变量):正文零时间戳、零随机——所有列表先排序再拼;
6
+ // 页面唯一的时刻 frontmatter.generatedAt 由 t0.js 从 state.json 沿用(实现计划
7
+ // P3:时刻收进 state.json,失败不推进,页面字节才可能跨 run 稳定)。
8
+ //
9
+ // 保留区规则(写死、可测):
10
+ // 1. 抽取:START…END 之间连同两行标记整体抽出,一个字节不动;
11
+ // 2. 回填:新正文之后按原相对顺序接回全部块;
12
+ // 3. 重生成永不删块、永不改写块内内容(包括缩进与格式)——红线 6;
13
+ // 4. 没有块时整页覆盖(spec:人在块外的手改不保证存活,是明说语义不是事故)。
14
+
15
+ export const TEMPLATE_VERSION = "1";
16
+
17
+ export const HUMAN_START = "<!-- CHORO:HUMAN:START -->";
18
+ export const HUMAN_END = "<!-- CHORO:HUMAN:END -->";
19
+
20
+ // T1 语义段的包装标记。写的人是 t1.js,读的人还有 t0.js——重跑 T0 时它要能认出
21
+ // 「这页已经被富化过」,否则 enriched → skeleton 的退回是静默的。标记随页面格式
22
+ // 走,所以与 HUMAN 标记同住这里,t1.js 转出给调用方。
23
+ export const T1_SECTION_START = "<!-- CHORO:T1:START -->";
24
+ export const T1_SECTION_END = "<!-- CHORO:T1:END -->";
25
+
26
+ /** 抽出全部保留区。返回 { blocks: string[](含标记本身,原样), count }。 */
27
+ export function extractHumanBlocks(pageText) {
28
+ if (typeof pageText !== "string") return { blocks: [], count: 0 };
29
+ const blocks = [];
30
+ const re = new RegExp(`${escapeRegExp(HUMAN_START)}[\\s\\S]*?${escapeRegExp(HUMAN_END)}`, "g");
31
+ let m;
32
+ while ((m = re.exec(pageText)) !== null) blocks.push(m[0]);
33
+ return { blocks, count: blocks.length };
34
+ }
35
+
36
+ /** 新正文 + 保留区(按原顺序接在页尾,各隔一个空行)。 */
37
+ export function injectHumanBlocks(newContent, blocks) {
38
+ if (!blocks || blocks.length === 0) return newContent;
39
+ return `${newContent.replace(/\n*$/, "\n")}\n${blocks.join("\n\n")}\n`;
40
+ }
41
+
42
+ /**
43
+ * 渲染一页骨架。meta: { slug, title, parents[], sourceFiles[], gitBaseline,
44
+ * generatedAt, status };mod 是 partition 的产物。
45
+ */
46
+ export function renderSkeletonPage(mod, meta) {
47
+ const lines = [];
48
+ lines.push("---");
49
+ lines.push("choro: v1");
50
+ lines.push(`slug: ${meta.slug}`);
51
+ lines.push(`title: ${yamlScalar(meta.title)}`);
52
+ if (meta.parents && meta.parents.length > 0) {
53
+ lines.push("parents:");
54
+ for (const p of meta.parents) lines.push(` - ${p}`);
55
+ } else {
56
+ lines.push("parents: []");
57
+ }
58
+ lines.push("sourceFiles:");
59
+ for (const f of meta.sourceFiles) lines.push(` - ${f}`);
60
+ lines.push(`gitBaseline: ${meta.gitBaseline}`);
61
+ lines.push(`templateVersion: ${TEMPLATE_VERSION}`);
62
+ lines.push(`language: ${meta.language ?? "zh"}`);
63
+ lines.push(`status: ${meta.status}`);
64
+ lines.push(`generatedAt: ${meta.generatedAt}`);
65
+ lines.push("---");
66
+ lines.push("");
67
+ lines.push(`# ${meta.title}`);
68
+ lines.push("");
69
+ // 注意:这行说明里**不得出现成对的标记字面量**——抽取用的是全局正则,说明行
70
+ // 里一旦同时含 START 与 END,下一轮重生成就会把它当成一块保留区接在页尾
71
+ // (实测:第二轮页面凭空多两行)。所以说标记时用名字,不用原文。
72
+ lines.push(
73
+ `> 确定性骨架(T0,零模型):由文件树与 import 图生成。` +
74
+ `CHORO:HUMAN 标记对(START/END 两个 HTML 注释)包住的内容在重生成时逐字节保留;` +
75
+ `标记对之外的手改不保留(想保住一段就把它挪进标记对里)。`
76
+ );
77
+ lines.push("");
78
+ lines.push(`## 文件清单(${mod.fileCount} 个)`);
79
+ lines.push("");
80
+ lines.push("| 文件 | 行数 |");
81
+ lines.push("| --- | ---: |");
82
+ for (const [file, lines2] of mod.fileLines) lines.push(`| ${file} | ${lines2} |`);
83
+ lines.push("");
84
+ lines.push("## 入口");
85
+ lines.push("");
86
+ if (mod.entries.length > 0) {
87
+ for (const e of mod.entries) lines.push(`- \`${e}\``);
88
+ } else {
89
+ lines.push("- 无(没有 index/main 命名的文件,也不是包入口)");
90
+ }
91
+ lines.push("");
92
+ lines.push("## 内部依赖");
93
+ lines.push("");
94
+ if (mod.upstream.length > 0) {
95
+ lines.push(`- 上游依赖(本模块引用的模块):${mod.upstream.map((s) => `\`${s}\``).join("、")}`);
96
+ }
97
+ if (mod.downstream.length > 0) {
98
+ lines.push(`- 下游(引用本模块的模块):${mod.downstream.map((s) => `\`${s}\``).join("、")}`);
99
+ }
100
+ if (mod.upstream.length === 0 && mod.downstream.length === 0) {
101
+ lines.push("- 无内部依赖边(孤立模块)");
102
+ }
103
+ lines.push("");
104
+ lines.push("## 外部依赖");
105
+ lines.push("");
106
+ if (mod.externalDeps.length > 0) {
107
+ for (const d of mod.externalDeps) lines.push(`- \`${d.pkg}\` ×${d.count}`);
108
+ } else {
109
+ lines.push("- 无外部 npm 依赖");
110
+ }
111
+ lines.push("");
112
+ lines.push("## 生成说明");
113
+ lines.push("");
114
+ lines.push(
115
+ `- 模块划分:目录初稿 + 兄弟组内聚合并(内部边 ≥3),超上限逐级下切;` +
116
+ `本组 ${mod.fileCount} 个文件。`
117
+ );
118
+ if (mod.unresolvedCount > 0) {
119
+ lines.push(`- ${mod.unresolvedCount} 条相对引用没有解析到仓库内文件(别名/动态路径),未计入依赖图。`);
120
+ }
121
+ lines.push("");
122
+ return lines.join("\n");
123
+ }
124
+
125
+ /** index.md:模块目录 + 仓库级信息。同样零时间戳(时刻在 state.json)。 */
126
+ export function renderIndex(modules, repoMeta) {
127
+ const lines = [];
128
+ lines.push("---");
129
+ lines.push("choro: v1");
130
+ lines.push(`repo: ${yamlScalar(repoMeta.name)}`);
131
+ lines.push(`gitBaseline: ${repoMeta.gitBaseline}`);
132
+ lines.push(`templateVersion: ${TEMPLATE_VERSION}`);
133
+ lines.push(`generatedAt: ${repoMeta.generatedAt}`);
134
+ lines.push("language: zh");
135
+ lines.push("---");
136
+ lines.push("");
137
+ lines.push(`# ${repoMeta.name} · 仓库地志`);
138
+ lines.push("");
139
+ lines.push(
140
+ `T0 确定性骨架(零模型)。页面在 \`modules/\` 下,真源是本目录的 Markdown;` +
141
+ `本文件整页机器所有、可随时重算(spec §4)。`
142
+ );
143
+ lines.push("");
144
+ lines.push(`- 扫描 ${repoMeta.fileCount} 个文件,划出 ${modules.length} 个模块。`);
145
+ if (repoMeta.skipped > 0) lines.push(`- ${repoMeta.skipped} 个文件读取失败,已跳过(页脚与 receipt 都有记)。`);
146
+ if (repoMeta.unresolved > 0) lines.push(`- ${repoMeta.unresolved} 条相对引用未解析(别名/动态路径),未计入依赖图。`);
147
+ lines.push("");
148
+ lines.push("## 模块目录");
149
+ lines.push("");
150
+ lines.push("| 模块 | 页面 | 文件数 | 内部边 | 外部依赖 |");
151
+ lines.push("| --- | --- | ---: | ---: | ---: |");
152
+ for (const mod of modules) {
153
+ const pageRel = `modules/${mod.slug}.md`;
154
+ lines.push(
155
+ `| ${mod.title} | [${mod.slug}](${pageRel}) | ${mod.fileCount} | ` +
156
+ `${mod.internalEdges.length} | ${mod.externalDeps.length} |`
157
+ );
158
+ }
159
+ lines.push("");
160
+ lines.push("## 脚本命令(根 package.json)");
161
+ lines.push("");
162
+ const scriptNames = Object.keys(repoMeta.scripts ?? {}).sort();
163
+ if (scriptNames.length > 0) {
164
+ lines.push("| 命令 | 内容 |");
165
+ lines.push("| --- | --- |");
166
+ for (const name of scriptNames) lines.push(`| \`${name}\` | ${escapeCell(String(repoMeta.scripts[name]))} |`);
167
+ } else {
168
+ lines.push("- 无 scripts 字段或没有根 package.json。");
169
+ }
170
+ lines.push("");
171
+ return lines.join("\n");
172
+ }
173
+
174
+ // YAML 标量:含冒号/引号等特殊字符时加引号(frontmatter 里的 title/repo 是人名或
175
+ // 目录名,通常无害,但这层便宜且省掉转义 bug)。
176
+ function yamlScalar(text) {
177
+ const s = String(text ?? "");
178
+ return /[:#'\"[\]{},&*?|>%@`]/.test(s) || s.trim() !== s ? JSON.stringify(s) : s;
179
+ }
180
+
181
+ function escapeCell(text) {
182
+ return text.replace(/\|/g, "\\|").replace(/\n/g, " ");
183
+ }
184
+
185
+ function escapeRegExp(text) {
186
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ }