@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,200 @@
|
|
|
1
|
+
// dsh-mneme/src/wiki/partition.js
|
|
2
|
+
// 模块划分(spec §6 步 4):目录为初稿,用图的内聚度做合并,超 max_files_per_page
|
|
3
|
+
// 再逐级下切。全流程确定性:同输入两次划分给出逐项相同的结果(spec §9 的可重建
|
|
4
|
+
// 不变量要求划分本身也稳定)。
|
|
5
|
+
//
|
|
6
|
+
// 启发式只有两条、都便宜到可解释(V2 要人判质量,判据必须能口述):
|
|
7
|
+
// 合并——同一父目录下的两个兄弟组,互相指向的内部边 ≥ MERGE_EDGE_MIN 且合并不超
|
|
8
|
+
// 上限时并成一组(典型收益:utils/ 与它唯一的消费者;典型损失:两个互不相干的
|
|
9
|
+
// 小目录被文件夹布局凑到一起,代价是页变大,不会错分语义)。
|
|
10
|
+
// 下切——超上限的组按下一级目录段切开;直属文件自成一组;叶子组仍超上限时按
|
|
11
|
+
// 文件名字典序切块(此时语义已无从谈起,保住「每页不超过上限」的契约)。
|
|
12
|
+
import { posix } from "node:path";
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
const MERGE_EDGE_MIN = 3;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {string[]} files 仓库相对路径(已排序)
|
|
19
|
+
* @param {{edges: {from:string,to:string}[], external: Map<string,Set<string>>}} graph scan 的产物
|
|
20
|
+
* @param {number} maxFilesPerPage plan.yaml 的上限
|
|
21
|
+
* @returns {Array<{slug,title,dir,fileCount,files,parentSlug,internalEdges,externalDeps,fingerprint}>}
|
|
22
|
+
*/
|
|
23
|
+
export function partitionModules({ files, graph, maxFilesPerPage, repoName }) {
|
|
24
|
+
const cap = Math.max(1, Math.floor(maxFilesPerPage));
|
|
25
|
+
|
|
26
|
+
// 步 1:目录初稿——前两级目录段;根直属文件一组。
|
|
27
|
+
const groups = new Map();
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const key = groupKeyOf(file);
|
|
30
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
31
|
+
groups.get(key).push(file);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 步 2:兄弟组合并(单遍、按 key 字典序,确定性)。
|
|
35
|
+
mergeSiblingGroups(groups, graph.edges, cap);
|
|
36
|
+
|
|
37
|
+
// 步 3:超限下切。
|
|
38
|
+
const finalGroups = [];
|
|
39
|
+
for (const key of [...groups.keys()].sort()) {
|
|
40
|
+
splitOversized(key, groups.get(key), cap, finalGroups);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 步 4:slug / title / 父链 / 边聚合。
|
|
44
|
+
const modules = finalGroups
|
|
45
|
+
.map((g) => materialize(g, graph))
|
|
46
|
+
.sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : a.slug < b.slug ? -1 : 1));
|
|
47
|
+
|
|
48
|
+
assignSlugsAndParents(modules, repoName);
|
|
49
|
+
for (const mod of modules) mod.fingerprint = moduleFingerprint(mod);
|
|
50
|
+
return modules;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function groupKeyOf(file) {
|
|
54
|
+
const dir = posix.dirname(file);
|
|
55
|
+
if (dir === ".") return "";
|
|
56
|
+
const segments = dir.split("/");
|
|
57
|
+
return segments.slice(0, 2).join("/");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function mergeSiblingGroups(groups, edges, cap) {
|
|
61
|
+
// 文件级边先折算到组级(两次查找的 Map,组数 = 目录数,量级无所谓)。
|
|
62
|
+
const cross = new Map(); // "a|b" -> count(a、b 为组 key,字典序小的在前)
|
|
63
|
+
for (const e of edges) {
|
|
64
|
+
const ga = groupKeyOf(e.from);
|
|
65
|
+
const gb = groupKeyOf(e.to);
|
|
66
|
+
if (ga === gb || !groups.has(ga) || !groups.has(gb)) continue;
|
|
67
|
+
const [x, y] = ga < gb ? [ga, gb] : [gb, ga];
|
|
68
|
+
const k = `${x}|${y}`;
|
|
69
|
+
cross.set(k, (cross.get(k) ?? 0) + 1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keys = [...groups.keys()].sort();
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
if (!groups.has(key)) continue; // 已被并走
|
|
75
|
+
const parent = posix.dirname(key || "·");
|
|
76
|
+
for (const [pairKey, count] of [...cross.entries()].sort()) {
|
|
77
|
+
if (count < MERGE_EDGE_MIN) continue;
|
|
78
|
+
const [a, b] = pairKey.split("|");
|
|
79
|
+
if (a !== key) continue;
|
|
80
|
+
if (!groups.has(a) || !groups.has(b)) continue;
|
|
81
|
+
if (posix.dirname(a || "·") !== parent || posix.dirname(b || "·") !== parent) continue;
|
|
82
|
+
if (groups.get(a).length + groups.get(b).length > cap) continue;
|
|
83
|
+
// 并入字典序小的组;被并组从账上消失(cross 里的旧键自然失效)。
|
|
84
|
+
groups.set(a, [...groups.get(a), ...groups.get(b)].sort());
|
|
85
|
+
groups.delete(b);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function splitOversized(key, files, cap, out) {
|
|
91
|
+
if (files.length <= cap) {
|
|
92
|
+
out.push({ dir: key, files });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
// 按下一级目录段细分;直属文件(无更深层)留在本组(subKey = key)。key 为空
|
|
96
|
+
// (根直属组)时 dirname 是 ".",嵌套文件直接用完整相对目录做子键——根组没有
|
|
97
|
+
// 「前缀可剥」。
|
|
98
|
+
const sub = new Map();
|
|
99
|
+
for (const file of files) {
|
|
100
|
+
const dir = posix.dirname(file);
|
|
101
|
+
let subKey;
|
|
102
|
+
if (key === "") {
|
|
103
|
+
subKey = dir === "." ? key : dir;
|
|
104
|
+
} else {
|
|
105
|
+
const rest = dir.startsWith(key + "/") ? dir.slice(key.length + 1) : "";
|
|
106
|
+
subKey = rest === "" ? key : `${key}/${rest.split("/")[0]}`;
|
|
107
|
+
}
|
|
108
|
+
if (!sub.has(subKey)) sub.set(subKey, []);
|
|
109
|
+
sub.get(subKey).push(file);
|
|
110
|
+
}
|
|
111
|
+
if (sub.size === 1) {
|
|
112
|
+
const [onlyKey] = sub.keys();
|
|
113
|
+
if (onlyKey === key) {
|
|
114
|
+
// 已是最深层目录、没有可下的子级:按文件名切块,块名带序号(语义无可言,保契约)。
|
|
115
|
+
chunkFiles(key, files, cap, out);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
// 整组都在一个更深的子目录里:继续往下切。
|
|
119
|
+
splitOversized(onlyKey, sub.get(onlyKey), cap, out);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const subKey of [...sub.keys()].sort()) {
|
|
123
|
+
splitOversized(subKey, sub.get(subKey), cap, out);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function chunkFiles(key, files, cap, out) {
|
|
128
|
+
for (let i = 0; i < files.length; i += cap) {
|
|
129
|
+
const slice = files.slice(i, i + cap);
|
|
130
|
+
out.push({ dir: i === 0 ? key : `${key}#${Math.floor(i / cap) + 1}`, files: slice });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function materialize(group, graph) {
|
|
135
|
+
const fileSet = new Set(group.files);
|
|
136
|
+
const internalEdges = graph.edges.filter((e) => fileSet.has(e.from) && fileSet.has(e.to));
|
|
137
|
+
const extCounts = new Map();
|
|
138
|
+
for (const [pkg, users] of graph.external) {
|
|
139
|
+
const n = [...users].filter((f) => fileSet.has(f)).length;
|
|
140
|
+
if (n > 0) extCounts.set(pkg, n);
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
dir: group.dir,
|
|
144
|
+
files: group.files,
|
|
145
|
+
fileCount: group.files.length,
|
|
146
|
+
internalEdges,
|
|
147
|
+
externalDeps: [...extCounts.entries()]
|
|
148
|
+
.map(([pkg, count]) => ({ pkg, count }))
|
|
149
|
+
.sort((a, b) => (a.pkg < b.pkg ? -1 : a.pkg > b.pkg ? 1 : b.count - a.count)),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function assignSlugsAndParents(modules, repoName) {
|
|
154
|
+
// slug:目录段连字化;重复加序号。空目录段(根直属)用 "root"。
|
|
155
|
+
const used = new Map();
|
|
156
|
+
for (const mod of modules) {
|
|
157
|
+
const base = mod.dir === "" ? "root" : slugify(mod.dir);
|
|
158
|
+
const n = used.get(base) ?? 0;
|
|
159
|
+
used.set(base, n + 1);
|
|
160
|
+
mod.slug = n === 0 ? base : `${base}-${n + 1}`;
|
|
161
|
+
mod.title = titleOf(mod, repoName);
|
|
162
|
+
}
|
|
163
|
+
// 父链:slug 集里目录包含关系最近的祖先(重名块 #2 算同层)。
|
|
164
|
+
for (const mod of modules) {
|
|
165
|
+
mod.parentSlug = null;
|
|
166
|
+
let best = null;
|
|
167
|
+
for (const other of modules) {
|
|
168
|
+
if (other === mod) continue;
|
|
169
|
+
if (other.dir === "") continue;
|
|
170
|
+
if (mod.dir === other.dir || mod.dir === "") continue;
|
|
171
|
+
if (!mod.dir.startsWith(other.dir + "/")) continue;
|
|
172
|
+
if (best === null || other.dir.length > best.dir.length) best = other;
|
|
173
|
+
}
|
|
174
|
+
if (best) mod.parentSlug = best.slug;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function slugify(dir) {
|
|
179
|
+
return dir
|
|
180
|
+
.toLowerCase()
|
|
181
|
+
.replace(/[^a-z0-9/]+/g, "-")
|
|
182
|
+
.replace(/\/+/g, "-")
|
|
183
|
+
.replace(/^-+|-+$/g, "") || "root";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function titleOf(mod, repoName) {
|
|
187
|
+
if (mod.dir === "") return repoName ? `${repoName}(仓库根)` : "仓库根";
|
|
188
|
+
const segments = mod.dir.split("/");
|
|
189
|
+
const last = segments[segments.length - 1];
|
|
190
|
+
// 目录名直接当标题(保持原文大小写);切块组带序号(dir 里的 # 是切块标记,
|
|
191
|
+
// slugify 之后 slug 里已无 #,所以判断要看 dir)。
|
|
192
|
+
return mod.dir.includes("#") ? `${last.replace(/#\d+$/, "")}(续 ${mod.dir.match(/#(\d+)$/)?.[1] ?? ""})` : last;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function moduleFingerprint(mod) {
|
|
196
|
+
const hash = createHash("sha256");
|
|
197
|
+
for (const f of mod.files) hash.update(`f:${f}\n`);
|
|
198
|
+
for (const e of mod.internalEdges) hash.update(`e:${e.from}>${e.to}\n`);
|
|
199
|
+
return hash.digest("hex");
|
|
200
|
+
}
|
package/lib/wiki/plan.js
ADDED
|
@@ -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
|
+
}
|