dsh-mastery-loop 0.0.0-stage → 0.2.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/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/cordis.patch.yml +10 -0
- package/lib/index.js +9 -0
- package/package.json +47 -4
- package/skills/mastery-loop/SKILL.md +201 -0
- package/skills/mastery-loop/exam-templates.md +102 -0
- package/skills/mastery-loop/scripts/_lib.mjs +408 -0
- package/skills/mastery-loop/scripts/aggregate.mjs +155 -0
- package/skills/mastery-loop/scripts/graph.mjs +448 -0
- package/skills/mastery-loop/scripts/ledger.mjs +223 -0
- package/skills/mastery-loop/scripts/rewrite-links.mjs +81 -0
- package/skills/mastery-loop/scripts/scan.mjs +276 -0
- package/skills/mastery-loop/scripts/validate.mjs +181 -0
- package/skills/mastery-loop/templates.md +255 -0
- package/skills/mastery-loop-setup/SKILL.md +161 -0
- package/skills/mastery-loop-setup/seeds/config.json +39 -0
- package/skills/mastery-loop-setup/seeds/error-taxonomy.json +4 -0
- package/skills/mastery-loop-setup/seeds/errors.jsonl +1 -0
- package/skills/mastery-loop-setup/seeds/graph.json +13 -0
- package/skills/mastery-loop-setup/seeds/ledger.jsonl +2 -0
- package/skills/mastery-loop-setup/seeds/moc.md +15 -0
- package/skills/mastery-loop-setup/seeds/note.md +33 -0
- package/skills/mastery-loop-setup/seeds/paper-format.json +15 -0
- package/skills/mastery-loop-setup/seeds/state.jsonl +2 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 共享工具(不单独使用)
|
|
3
|
+
//
|
|
4
|
+
// 用途:本文件是其它脚本共用的最小工具集 —— 读写 JSON / JSONL、解析 frontmatter、
|
|
5
|
+
// 收集知识点、扫 wikilink、替换 AUTO 区。**零依赖、不拼 shell。**
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, mkdirSync } from 'node:fs';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { join, relative, dirname, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const KB = 'kb';
|
|
12
|
+
export const NOTES = join('kb', 'knowledge');
|
|
13
|
+
export const MOC = join('kb', 'MOC');
|
|
14
|
+
export const SYS = 'sys';
|
|
15
|
+
export const AUTO_BEGIN = '<!-- AUTO:BEGIN -->';
|
|
16
|
+
export const AUTO_END = '<!-- AUTO:END -->';
|
|
17
|
+
|
|
18
|
+
export function readIfPossible(p) {
|
|
19
|
+
try {
|
|
20
|
+
return readFileSync(p, 'utf8');
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function writeText(p, text) {
|
|
27
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
28
|
+
writeFileSync(p, text, 'utf8');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sha1(text) {
|
|
32
|
+
return 'sha1:' + createHash('sha1').update(text, 'utf8').digest('hex').slice(0, 16);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function readJson(p) {
|
|
36
|
+
const t = readIfPossible(p);
|
|
37
|
+
if (t === null) return { ok: false, reason: `读不了:${p}` };
|
|
38
|
+
try {
|
|
39
|
+
return { ok: true, data: JSON.parse(t) };
|
|
40
|
+
} catch (err) {
|
|
41
|
+
return { ok: false, reason: `${p} 不是合法 JSON:${err.message}` };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** JSONL:第一行是 header(kind: header),其余是数据行 */
|
|
46
|
+
export function readJsonl(p) {
|
|
47
|
+
const t = readIfPossible(p);
|
|
48
|
+
if (t === null) return { ok: false, reason: `读不了:${p}` };
|
|
49
|
+
const lines = t.split(/\r?\n/).filter((l) => l.trim());
|
|
50
|
+
const rows = [];
|
|
51
|
+
let header = null;
|
|
52
|
+
lines.forEach((l, i) => {
|
|
53
|
+
let obj;
|
|
54
|
+
try {
|
|
55
|
+
obj = JSON.parse(l);
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (i === 0 && obj && obj.kind === 'header') header = obj;
|
|
60
|
+
else rows.push(obj);
|
|
61
|
+
});
|
|
62
|
+
return { ok: true, header, rows };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeJsonl(p, header, rows) {
|
|
66
|
+
const text = [JSON.stringify(header), ...rows.map((r) => JSON.stringify(r))].join('\n') + '\n';
|
|
67
|
+
writeText(p, text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------- frontmatter(只支持契约用到的那几种写法) ----------
|
|
71
|
+
|
|
72
|
+
/** 切出 frontmatter 文本与正文 */
|
|
73
|
+
export function splitFrontmatter(text) {
|
|
74
|
+
const m = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n?/.exec(text);
|
|
75
|
+
if (!m) return { fm: null, body: text };
|
|
76
|
+
return { fm: m[1], body: text.slice(m[0].length) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseScalar(s) {
|
|
80
|
+
let v = s.trim();
|
|
81
|
+
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
|
|
82
|
+
v = v.slice(1, -1);
|
|
83
|
+
}
|
|
84
|
+
return v;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 行内列表:["a/b", "c"] —— 引号内的逗号/括号不切 */
|
|
88
|
+
function parseInlineList(s) {
|
|
89
|
+
const inner = s.trim().replace(/^\[/, '').replace(/\]$/, '');
|
|
90
|
+
const out = [];
|
|
91
|
+
let cur = '';
|
|
92
|
+
let quote = null;
|
|
93
|
+
for (let i = 0; i < inner.length; i++) {
|
|
94
|
+
const ch = inner[i];
|
|
95
|
+
if (quote) {
|
|
96
|
+
if (ch === quote) quote = null;
|
|
97
|
+
cur += ch;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '"' || ch === "'") {
|
|
101
|
+
quote = ch;
|
|
102
|
+
cur += ch;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === ',') {
|
|
106
|
+
out.push(cur);
|
|
107
|
+
cur = '';
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
cur += ch;
|
|
111
|
+
}
|
|
112
|
+
if (cur.trim()) out.push(cur);
|
|
113
|
+
return out.map(parseScalar).filter((x) => x !== '');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 极简 YAML 子集 → 对象(够契约用) */
|
|
117
|
+
export function parseFrontmatter(fmText) {
|
|
118
|
+
const data = {};
|
|
119
|
+
if (!fmText) return data;
|
|
120
|
+
for (const line of fmText.split(/\r?\n/)) {
|
|
121
|
+
const m = /^([A-Za-z0-9_\u4e00-\u9fa5]+)\s*:\s*(.*)$/.exec(line);
|
|
122
|
+
if (!m) continue;
|
|
123
|
+
const key = m[1];
|
|
124
|
+
const raw = m[2].trim();
|
|
125
|
+
if (raw === '' || raw === '>-' || raw === '|') {
|
|
126
|
+
data[key] = '';
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (raw.startsWith('[')) data[key] = parseInlineList(raw);
|
|
130
|
+
else data[key] = parseScalar(raw);
|
|
131
|
+
}
|
|
132
|
+
return data;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- 知识点 ----------
|
|
136
|
+
|
|
137
|
+
export function collectNotes(root) {
|
|
138
|
+
const map = new Map();
|
|
139
|
+
const dir = join(root, NOTES);
|
|
140
|
+
const walk = (d, depth) => {
|
|
141
|
+
if (depth > 4) return;
|
|
142
|
+
let entries = [];
|
|
143
|
+
try {
|
|
144
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
145
|
+
} catch {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
for (const e of entries) {
|
|
149
|
+
if (e.name.startsWith('.')) continue;
|
|
150
|
+
const p = join(d, e.name);
|
|
151
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
152
|
+
else if (/\.md$/i.test(e.name)) map.set(e.name.replace(/\.md$/i, ''), p);
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
walk(dir, 0);
|
|
156
|
+
return map;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function noteName(raw) {
|
|
160
|
+
let s = String(raw || '').trim();
|
|
161
|
+
s = s.replace(/^\[\[/, '').replace(/\]\]$/, '');
|
|
162
|
+
const pipe = s.indexOf('|');
|
|
163
|
+
if (pipe !== -1) s = s.slice(0, pipe);
|
|
164
|
+
s = s.replace(/\.md$/i, '');
|
|
165
|
+
const parts = s.split(/[\\/]/);
|
|
166
|
+
return parts[parts.length - 1].trim();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** 笔记名 + 分类值 + 一句话 */
|
|
170
|
+
export function readNote(root, name, path, cfg) {
|
|
171
|
+
const text = readIfPossible(path) || '';
|
|
172
|
+
const { fm, body } = splitFrontmatter(text);
|
|
173
|
+
const data = parseFrontmatter(fm);
|
|
174
|
+
const field = cfg?.分类?.字段 || '分类';
|
|
175
|
+
const csep = cfg?.分类?.分隔符 || '/';
|
|
176
|
+
const raw = data[field];
|
|
177
|
+
const 分类 = Array.isArray(raw) ? raw : raw ? [raw] : [];
|
|
178
|
+
const one = /##\s*一句话\s*\n+([^\n#]+)/.exec(body);
|
|
179
|
+
return {
|
|
180
|
+
name,
|
|
181
|
+
path,
|
|
182
|
+
rel: relative(root, path).split(sep).join('/'),
|
|
183
|
+
分类: 分类.map((c) => String(c).split(csep).map((x) => x.trim()).filter(Boolean)),
|
|
184
|
+
一句话: one ? one[1].trim() : '',
|
|
185
|
+
text,
|
|
186
|
+
data,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------- 正文小节 ----------
|
|
191
|
+
|
|
192
|
+
export function sectionRange(text, heading) {
|
|
193
|
+
const re = new RegExp('^##\\s*' + heading + '\\s*$', 'm');
|
|
194
|
+
const m = re.exec(text);
|
|
195
|
+
if (!m) return null;
|
|
196
|
+
const after = text.indexOf('\n', m.index);
|
|
197
|
+
if (after === -1) return { headStart: m.index, bodyStart: text.length, end: text.length };
|
|
198
|
+
const rest = text.slice(after + 1);
|
|
199
|
+
const nextRel = rest.search(/^##[ \t]/m);
|
|
200
|
+
const end = nextRel === -1 ? text.length : after + 1 + nextRel;
|
|
201
|
+
return { headStart: m.index, bodyStart: after + 1, end };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function sectionBullets(text, heading) {
|
|
205
|
+
const r = sectionRange(text, heading);
|
|
206
|
+
if (!r) return null;
|
|
207
|
+
const body = text.slice(r.bodyStart, r.end);
|
|
208
|
+
const names = [];
|
|
209
|
+
for (const line of body.split(/\r?\n/)) {
|
|
210
|
+
const mm = line.match(/^\s*[-*]\s+\[\[([^\]]+?)\]\]/);
|
|
211
|
+
if (mm) names.push(noteName(mm[1]));
|
|
212
|
+
}
|
|
213
|
+
return names;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function replaceSection(text, heading, bullets) {
|
|
217
|
+
const r = sectionRange(text, heading);
|
|
218
|
+
if (!r) return { text, found: false };
|
|
219
|
+
const body = bullets.length ? bullets.join('\n') : '_(暂无)_';
|
|
220
|
+
return {
|
|
221
|
+
text: `${text.slice(0, r.headStart)}## ${heading}\n\n${body}\n\n${text.slice(r.end)}`,
|
|
222
|
+
found: true,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---------- AUTO 区 ----------
|
|
227
|
+
|
|
228
|
+
export function replaceAutoZone(text, lines) {
|
|
229
|
+
const b = text.indexOf(AUTO_BEGIN);
|
|
230
|
+
const e = text.indexOf(AUTO_END);
|
|
231
|
+
const block = `${AUTO_BEGIN}\n${lines.join('\n')}\n${AUTO_END}`;
|
|
232
|
+
if (b === -1 || e === -1 || e < b) {
|
|
233
|
+
// 没有标记:原地补一个,放在第一个 `## ` 之前(或文件末尾)
|
|
234
|
+
const at = text.search(/^##[ \t]/m);
|
|
235
|
+
const body = `${block}\n\n`;
|
|
236
|
+
return at === -1 ? text.trimEnd() + '\n\n' + body : text.slice(0, at) + body + text.slice(at);
|
|
237
|
+
}
|
|
238
|
+
return text.slice(0, b) + block + text.slice(e + AUTO_END.length);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ---------- 链接 ----------
|
|
242
|
+
|
|
243
|
+
export function parseLinks(text) {
|
|
244
|
+
const out = [];
|
|
245
|
+
const re = /\[\[([^\]\n]+?)\]\]/g;
|
|
246
|
+
let m;
|
|
247
|
+
while ((m = re.exec(text))) {
|
|
248
|
+
const raw = m[1];
|
|
249
|
+
const pipe = raw.indexOf('|');
|
|
250
|
+
const target = (pipe === -1 ? raw : raw.slice(0, pipe)).trim();
|
|
251
|
+
const alias = pipe === -1 ? '' : raw.slice(pipe + 1).trim();
|
|
252
|
+
out.push({ raw, target, alias });
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function walkMarkdown(root, dirRel = '.', depth = 0, out = []) {
|
|
258
|
+
if (depth > 5) return out;
|
|
259
|
+
const dir = join(root, dirRel);
|
|
260
|
+
let entries = [];
|
|
261
|
+
try {
|
|
262
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
263
|
+
} catch {
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
for (const e of entries) {
|
|
267
|
+
if (e.name.startsWith('.')) continue;
|
|
268
|
+
const rel = dirRel === '.' ? e.name : join(dirRel, e.name);
|
|
269
|
+
if (e.isDirectory()) walkMarkdown(root, rel, depth + 1, out);
|
|
270
|
+
else if (/\.md$/i.test(e.name)) out.push(join(root, rel));
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function existsDir(p) {
|
|
276
|
+
return existsSync(p) && statSync(p).isDirectory();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ⚠️ 下面是**未被调用的副本**:图的单一实现是 `graph.mjs` 的 `check()`(`scan.mjs` 直接 import 它)。
|
|
280
|
+
// 保留只为对照;下次清理时删掉,别再往这里加逻辑。
|
|
281
|
+
|
|
282
|
+
export function checkGraph(root) {
|
|
283
|
+
const problems = [];
|
|
284
|
+
const warnings = [];
|
|
285
|
+
const g = readJson(join(root, SYS, 'graph.json'));
|
|
286
|
+
if (!g.ok) {
|
|
287
|
+
return { ok: false, reason: g.reason, problems, warnings, edges: [], notes: new Map(), involved: new Set() };
|
|
288
|
+
}
|
|
289
|
+
const data = g.data;
|
|
290
|
+
const edges = Array.isArray(data.edges) ? data.edges : [];
|
|
291
|
+
const notes = collectNotes(root);
|
|
292
|
+
const hasNote = (name) => notes.has(name);
|
|
293
|
+
|
|
294
|
+
const fwd = new Map();
|
|
295
|
+
const bwd = new Map();
|
|
296
|
+
const addTo = (m, k, v) => {
|
|
297
|
+
if (!m.has(k)) m.set(k, new Set());
|
|
298
|
+
m.get(k).add(v);
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
edges.forEach((e, i) => {
|
|
302
|
+
const at = `edges[${i}]`;
|
|
303
|
+
const from = noteName(e && e.from);
|
|
304
|
+
const to = noteName(e && e.to);
|
|
305
|
+
const reason = e && typeof e.reason === 'string' ? e.reason.trim() : '';
|
|
306
|
+
if (!from || !to) {
|
|
307
|
+
problems.push(`${at}:缺 from 或 to`);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (from === to) problems.push(`无自链 ✖ ${at}:${from} → ${to}`);
|
|
311
|
+
if (!hasNote(from)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${from}」`);
|
|
312
|
+
if (!hasNote(to)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${to}」`);
|
|
313
|
+
if (!reason) problems.push(`${at}:缺 reason(每条边必须 ≤30 字理由)`);
|
|
314
|
+
else if ([...reason].length > 30) problems.push(`${at}:reason ${[...reason].length} 字 > 30(${reason})`);
|
|
315
|
+
addTo(fwd, from, to);
|
|
316
|
+
addTo(bwd, to, from);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
const involved = new Set([...fwd.keys(), ...bwd.keys()]);
|
|
320
|
+
|
|
321
|
+
for (const [name, path] of notes) {
|
|
322
|
+
const text = readIfPossible(path);
|
|
323
|
+
if (text === null) {
|
|
324
|
+
warnings.push(`读不了:${path}`);
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const pre = sectionBullets(text, '前置知识');
|
|
328
|
+
const post = sectionBullets(text, '后置知识');
|
|
329
|
+
if (involved.has(name)) {
|
|
330
|
+
if (pre === null) warnings.push(`「${name}」缺 \`## 前置知识\` 小节`);
|
|
331
|
+
if (post === null) warnings.push(`「${name}」缺 \`## 后置知识\` 小节`);
|
|
332
|
+
}
|
|
333
|
+
for (const [label, list] of [['前置知识', pre], ['后置知识', post]]) {
|
|
334
|
+
if (!list) continue;
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
for (const n of list) {
|
|
337
|
+
if (seen.has(n)) problems.push(`同段无重复 ✖ 「${name}」\`## ${label}\` 里「${n}」出现两次`);
|
|
338
|
+
seen.add(n);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (pre && post) {
|
|
342
|
+
const both = [...new Set(pre.filter((n) => post.includes(n)))];
|
|
343
|
+
if (both.length) problems.push(`前后置互斥 ✖ 「${name}」同时出现在前置与后置:${both.join('、')}`);
|
|
344
|
+
}
|
|
345
|
+
if (pre) {
|
|
346
|
+
for (const n of new Set(pre)) {
|
|
347
|
+
if (!(bwd.get(name) || new Set()).has(n)) {
|
|
348
|
+
problems.push(`双向自洽 ✖ 「${name}」的 \`## 前置知识\` 里有「${n}」,但图里没有这条边`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (post) {
|
|
353
|
+
for (const n of new Set(post)) {
|
|
354
|
+
if (!(fwd.get(name) || new Set()).has(n)) {
|
|
355
|
+
problems.push(`双向自洽 ✖ 「${name}」的 \`## 后置知识\` 里有「${n}」,但图里没有这条边`);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const [from, tos] of fwd) {
|
|
362
|
+
for (const to of tos) {
|
|
363
|
+
const t1 = notes.has(from) ? readIfPossible(notes.get(from)) : null;
|
|
364
|
+
if (t1 !== null) {
|
|
365
|
+
const post = sectionBullets(t1, '后置知识') || [];
|
|
366
|
+
if (!post.includes(to)) problems.push(`双向自洽 ✖ 「${from}」的 \`## 后置知识\` 缺「${to}」(图里有这条边)`);
|
|
367
|
+
}
|
|
368
|
+
const t2 = notes.has(to) ? readIfPossible(notes.get(to)) : null;
|
|
369
|
+
if (t2 !== null) {
|
|
370
|
+
const pre = sectionBullets(t2, '前置知识') || [];
|
|
371
|
+
if (!pre.includes(from)) problems.push(`双向自洽 ✖ 「${to}」的 \`## 前置知识\` 缺「${from}」(图里有这条边)`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const reach = (start) => {
|
|
377
|
+
const seen = new Set([start]);
|
|
378
|
+
const stack = [...(fwd.get(start) || [])];
|
|
379
|
+
while (stack.length) {
|
|
380
|
+
const cur = stack.pop();
|
|
381
|
+
if (seen.has(cur)) continue;
|
|
382
|
+
seen.add(cur);
|
|
383
|
+
for (const t of fwd.get(cur) || []) if (!seen.has(t)) stack.push(t);
|
|
384
|
+
}
|
|
385
|
+
seen.delete(start);
|
|
386
|
+
return seen;
|
|
387
|
+
};
|
|
388
|
+
for (const name of involved) {
|
|
389
|
+
const path = notes.get(name);
|
|
390
|
+
if (!path) continue;
|
|
391
|
+
const list = sectionBullets(readIfPossible(path) || '', '前置知识');
|
|
392
|
+
if (!list) continue;
|
|
393
|
+
const uniq = [...new Set(list)];
|
|
394
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
395
|
+
for (let j = i + 1; j < uniq.length; j++) {
|
|
396
|
+
const a = uniq[i];
|
|
397
|
+
const b = uniq[j];
|
|
398
|
+
if (reach(a).has(b)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${a}」已经是「${b}」的前置 —— 只收直接前置`);
|
|
399
|
+
else if (reach(b).has(a)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${b}」已经是「${a}」的前置 —— 只收直接前置`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
if (data['用途'] === undefined) warnings.push('graph.json 缺「用途」字段');
|
|
405
|
+
if (data['规则'] === undefined) warnings.push('graph.json 缺「规则」字段');
|
|
406
|
+
|
|
407
|
+
return { ok: true, data, edges, notes, involved, problems, warnings, fwd, bwd };
|
|
408
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// 聚合脚本
|
|
3
|
+
//
|
|
4
|
+
// 用途:本脚本把知识点**按 `分类` 字段聚合**成 kb/MOC/ 下的机器区(只写 AUTO 区,手写区永不碰)。
|
|
5
|
+
// 契约正文:../templates.md(第一、五节)
|
|
6
|
+
//
|
|
7
|
+
// 用法:node aggregate.mjs <工作区根> [--scope <类路径>] [--dry]
|
|
8
|
+
//
|
|
9
|
+
// 只做聚合:不写待办、不产报告。
|
|
10
|
+
|
|
11
|
+
import { existsSync, statSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
SYS, MOC, AUTO_BEGIN, AUTO_END, readJson, readIfPossible, writeText,
|
|
15
|
+
collectNotes, readNote, replaceAutoZone,
|
|
16
|
+
} from './_lib.mjs';
|
|
17
|
+
|
|
18
|
+
const SKELETON = (title) =>
|
|
19
|
+
`# ${title}\n\n> 本文件是聚合产物:机器区由 \`aggregate.mjs\` 从知识点的 \`分类\` 生成并覆盖;手写区脚本永不碰。\n\n` +
|
|
20
|
+
`${AUTO_BEGIN}\n_(还没聚合)_\n${AUTO_END}\n\n## 主线(手写区)\n\n`;
|
|
21
|
+
|
|
22
|
+
function declaredValues(cfg) {
|
|
23
|
+
const levels = (cfg?.['分类']?.['层级']) || [];
|
|
24
|
+
const values = cfg?.['分类']?.['类值'] || {};
|
|
25
|
+
return { levels, values };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function main() {
|
|
29
|
+
const argv = process.argv.slice(2);
|
|
30
|
+
const root = resolve(argv.find((a) => !a.startsWith('--')) || '.');
|
|
31
|
+
const dry = argv.includes('--dry');
|
|
32
|
+
const si = argv.indexOf('--scope');
|
|
33
|
+
const scope = si !== -1 && argv[si + 1] ? argv[si + 1].split('/').map((s) => s.trim()).filter(Boolean) : null;
|
|
34
|
+
const sep = '/';
|
|
35
|
+
|
|
36
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
37
|
+
console.log(`❌ 工作区根不存在:${root}`);
|
|
38
|
+
return 2;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const c = readJson(join(root, SYS, 'config.json'));
|
|
42
|
+
if (!c.ok) {
|
|
43
|
+
console.log(`❌ ${c.reason}`);
|
|
44
|
+
return 1;
|
|
45
|
+
}
|
|
46
|
+
const cfg = c.data;
|
|
47
|
+
const { levels, values } = declaredValues(cfg);
|
|
48
|
+
|
|
49
|
+
const notes = [...collectNotes(root)].map(([name, p]) => readNote(root, name, p, cfg));
|
|
50
|
+
|
|
51
|
+
// 组节点:key = 类路径
|
|
52
|
+
const nodes = new Map();
|
|
53
|
+
const touch = (parts) => {
|
|
54
|
+
const key = parts.join(sep);
|
|
55
|
+
if (!nodes.has(key)) nodes.set(key, { parts, notes: [], children: new Set() });
|
|
56
|
+
return nodes.get(key);
|
|
57
|
+
};
|
|
58
|
+
const declaredOnly = (partList) => {
|
|
59
|
+
// config 里声明过的类值也算节点(哪怕是空的)
|
|
60
|
+
let cur = [];
|
|
61
|
+
for (let i = 0; i < levels.length; i++) {
|
|
62
|
+
const level = levels[i];
|
|
63
|
+
for (const v of values[level] || []) {
|
|
64
|
+
const parts = [...cur, v['值']];
|
|
65
|
+
touch(parts);
|
|
66
|
+
if (cur.length) touch(cur).children.add(parts.join(sep));
|
|
67
|
+
}
|
|
68
|
+
// 下一层的 cur 只有一条链时没法枚举;这里只铺第一层
|
|
69
|
+
if (i === 0) break;
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
declaredOnly();
|
|
73
|
+
|
|
74
|
+
for (const n of notes) {
|
|
75
|
+
if (!n.分类.length) continue;
|
|
76
|
+
for (const parts of n.分类) {
|
|
77
|
+
const trimmed = levels.length ? parts.slice(0, levels.length) : parts;
|
|
78
|
+
const node = touch(trimmed);
|
|
79
|
+
node.notes.push(n);
|
|
80
|
+
for (let i = 1; i <= trimmed.length; i++) {
|
|
81
|
+
const parent = trimmed.slice(0, i - 1);
|
|
82
|
+
if (parent.length) touch(parent).children.add(trimmed.slice(0, i).join(sep));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const inScope = (key) =>
|
|
88
|
+
!scope || key === scope.join(sep) || key.startsWith(scope.join(sep) + sep);
|
|
89
|
+
|
|
90
|
+
const depth = (n) => n.parts.length;
|
|
91
|
+
const leafDepth = Math.max(1, levels.length);
|
|
92
|
+
|
|
93
|
+
let written = 0;
|
|
94
|
+
let created = 0;
|
|
95
|
+
|
|
96
|
+
const writeOne = (rel, title, lines) => {
|
|
97
|
+
const path = join(root, rel);
|
|
98
|
+
const existed = existsSync(path);
|
|
99
|
+
const text = existed ? readIfPossible(path) : null;
|
|
100
|
+
const next = replaceAutoZone(text === null ? SKELETON(title) : text, lines);
|
|
101
|
+
if (next === text) return;
|
|
102
|
+
written++;
|
|
103
|
+
if (!existed) created++;
|
|
104
|
+
if (!dry) writeText(path, next);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// 全库总览(只有不带 scope 时才写)
|
|
108
|
+
if (!scope) {
|
|
109
|
+
const firstKeys = [...nodes.keys()].filter((k) => nodes.get(k).parts.length === 1).sort();
|
|
110
|
+
const lines = firstKeys.map((k) => {
|
|
111
|
+
const n = nodes.get(k);
|
|
112
|
+
const rel = `kb/MOC/${n.parts[0]}/00 ${n.parts[0]}(索引).md`;
|
|
113
|
+
return `- [[${rel}|${n.parts[0]}]] —— ${n.notes.length} 个知识点`;
|
|
114
|
+
});
|
|
115
|
+
writeOne(join(MOC, '00 总览.md'), '总览(全库)', lines.length ? lines : ['_(还没有任何分类)_']);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
for (const [key, node] of nodes) {
|
|
119
|
+
if (!inScope(key)) continue;
|
|
120
|
+
const d = depth(node);
|
|
121
|
+
const dir = join(MOC, ...node.parts);
|
|
122
|
+
const isLeaf = d >= leafDepth;
|
|
123
|
+
const title = `${node.parts[node.parts.length - 1]}(${isLeaf ? 'MOC' : '索引'})`;
|
|
124
|
+
const rel = join(dir, `00 ${node.parts[node.parts.length - 1]}(${isLeaf ? 'MOC' : '索引'}).md`);
|
|
125
|
+
|
|
126
|
+
let lines;
|
|
127
|
+
if (isLeaf) {
|
|
128
|
+
lines = node.notes
|
|
129
|
+
.sort((a, b) => a.name.localeCompare(b.name, 'zh'))
|
|
130
|
+
.map((n) => `- [[${n.rel}|${n.name}]]${n.一句话 ? ` —— ${n.一句话}` : ''}`);
|
|
131
|
+
if (!lines.length) lines = ['_(这一讲还没有知识点)_'];
|
|
132
|
+
} else {
|
|
133
|
+
const kids = [...node.children].sort();
|
|
134
|
+
lines = kids.map((k) => {
|
|
135
|
+
const child = nodes.get(k);
|
|
136
|
+
const name = k.split(sep).pop();
|
|
137
|
+
const childLeaf = child.parts.length >= leafDepth;
|
|
138
|
+
const childRel = `kb/MOC/${child.parts.join('/')}/00 ${name}(${childLeaf ? 'MOC' : '索引'}).md`;
|
|
139
|
+
return `- [[${childRel}|${name}]] —— ${child.notes.length} 个知识点`;
|
|
140
|
+
});
|
|
141
|
+
if (!lines.length) lines = ['_(还没有下一层)_'];
|
|
142
|
+
}
|
|
143
|
+
writeOne(rel, title, lines);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log(`聚合完成:${nodes.size} 个类节点 · ${dry ? '试算' : '已写'} ${written} 个文件(新建 ${created})。`);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
process.exit(main());
|
|
152
|
+
} catch (err) {
|
|
153
|
+
console.log('❌ aggregate.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|