dsh-mastery-loop 0.1.0 → 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 +30 -0
- package/package.json +1 -1
- package/skills/mastery-loop/SKILL.md +22 -21
- 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 +115 -59
- package/skills/mastery-loop/templates.md +210 -62
- package/skills/mastery-loop-setup/SKILL.md +105 -76
- 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
- package/skills/mastery-loop-setup/seeds/config.md +0 -34
- package/skills/mastery-loop-setup/seeds/error-taxonomy.md +0 -17
- package/skills/mastery-loop-setup/seeds/errors.md +0 -48
- package/skills/mastery-loop-setup/seeds/index.md +0 -25
- package/skills/mastery-loop-setup/seeds/ledger.md +0 -26
- package/skills/mastery-loop-setup/seeds/paper-format.md +0 -30
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mastery-loop 关系图工具
|
|
3
|
+
//
|
|
4
|
+
// 用途:本脚本是关系图的读写器 —— 管 sys/graph.json(只存边;节点就是 kb/knowledge/ 里的笔记本身),
|
|
5
|
+
// 做六项校验,并把两个小节(## 前置知识 / ## 后置知识)渲染回笔记。
|
|
6
|
+
// 契约正文:../templates.md(第二、三、七节)
|
|
7
|
+
//
|
|
8
|
+
// 用法:
|
|
9
|
+
// node graph.mjs check <工作区根>
|
|
10
|
+
// node graph.mjs backlinks <工作区根> [--write]
|
|
11
|
+
// node graph.mjs sort <工作区根> --note <笔记名> [--folder <已注册 folder 名>] [--limit N]
|
|
12
|
+
//
|
|
13
|
+
// 硬要求:**永远不要崩溃** —— 缺文件、坏 JSON、目录不存在都只报错并以退出码结束。
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, resolve, relative, basename, dirname, sep } from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
|
|
19
|
+
const MIYO_ENDPOINT = 'http://127.0.0.1:8742/v0/search/related';
|
|
20
|
+
const GRAPH_REL = join('sys', 'graph.json');
|
|
21
|
+
const NOTES_REL = join('kb', 'knowledge');
|
|
22
|
+
|
|
23
|
+
// ---------- 基础 ----------
|
|
24
|
+
|
|
25
|
+
function readIfPossible(path) {
|
|
26
|
+
try {
|
|
27
|
+
return readFileSync(path, 'utf8');
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 笔记名:去掉路径、去掉 .md、去掉 |别名、去掉 [[ ]] */
|
|
34
|
+
function noteName(raw) {
|
|
35
|
+
let s = String(raw || '').trim();
|
|
36
|
+
s = s.replace(/^\[\[/, '').replace(/\]\]$/, '');
|
|
37
|
+
const pipe = s.indexOf('|');
|
|
38
|
+
if (pipe !== -1) s = s.slice(0, pipe);
|
|
39
|
+
s = s.replace(/\.md$/i, '');
|
|
40
|
+
const parts = s.split(/[\\/]/);
|
|
41
|
+
return parts[parts.length - 1].trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function notesDir(root) {
|
|
45
|
+
const d = join(root, NOTES_REL);
|
|
46
|
+
return existsSync(d) && statSync(d).isDirectory() ? d : join(root, 'kb', 'knowledge');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** name -> 绝对路径 */
|
|
50
|
+
function collectNotes(root) {
|
|
51
|
+
const map = new Map();
|
|
52
|
+
const walk = (d, depth) => {
|
|
53
|
+
if (depth > 3) return;
|
|
54
|
+
let entries = [];
|
|
55
|
+
try {
|
|
56
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
57
|
+
} catch {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const e of entries) {
|
|
61
|
+
if (e.name.startsWith('.')) continue;
|
|
62
|
+
const p = join(d, e.name);
|
|
63
|
+
if (e.isDirectory()) walk(p, depth + 1);
|
|
64
|
+
else if (/\.md$/i.test(e.name)) map.set(e.name.replace(/\.md$/i, ''), p);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
walk(notesDir(root), 0);
|
|
68
|
+
return map;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function loadGraph(root) {
|
|
72
|
+
const path = join(root, GRAPH_REL);
|
|
73
|
+
if (!existsSync(path)) return { ok: false, reason: `没有图文件:${path}`, path };
|
|
74
|
+
const text = readIfPossible(path);
|
|
75
|
+
if (text === null) return { ok: false, reason: `图文件读不了:${path}`, path };
|
|
76
|
+
let data;
|
|
77
|
+
try {
|
|
78
|
+
data = JSON.parse(text);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
return { ok: false, reason: `图文件不是合法 JSON:${err.message}`, path };
|
|
81
|
+
}
|
|
82
|
+
if (!data || typeof data !== 'object') return { ok: false, reason: `图文件不是对象:${path}`, path };
|
|
83
|
+
const edges = Array.isArray(data.edges) ? data.edges : [];
|
|
84
|
+
return { ok: true, path, data, edges };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------- 正文小节 ----------
|
|
88
|
+
|
|
89
|
+
function sectionRange(text, heading) {
|
|
90
|
+
const re = new RegExp('^##\\s*' + heading + '\\s*$', 'm');
|
|
91
|
+
const m = re.exec(text);
|
|
92
|
+
if (!m) return null;
|
|
93
|
+
const after = text.indexOf('\n', m.index);
|
|
94
|
+
if (after === -1) return { headStart: m.index, bodyStart: text.length, end: text.length };
|
|
95
|
+
const rest = text.slice(after + 1);
|
|
96
|
+
const nextRel = rest.search(/^##[ \t]/m);
|
|
97
|
+
const end = nextRel === -1 ? text.length : after + 1 + nextRel;
|
|
98
|
+
return { headStart: m.index, bodyStart: after + 1, end };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function sectionBullets(text, heading) {
|
|
102
|
+
const r = sectionRange(text, heading);
|
|
103
|
+
if (!r) return null;
|
|
104
|
+
const body = text.slice(r.bodyStart, r.end);
|
|
105
|
+
const names = [];
|
|
106
|
+
for (const line of body.split(/\r?\n/)) {
|
|
107
|
+
const mm = line.match(/^\s*[-*]\s+\[\[([^\]]+?)\]\]/);
|
|
108
|
+
if (mm) names.push(noteName(mm[1]));
|
|
109
|
+
}
|
|
110
|
+
return names;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function replaceSection(text, heading, bullets) {
|
|
114
|
+
const r = sectionRange(text, heading);
|
|
115
|
+
if (!r) return { text, found: false };
|
|
116
|
+
const before = text.slice(0, r.headStart);
|
|
117
|
+
const after = text.slice(r.end);
|
|
118
|
+
const body = bullets.length ? bullets.join('\n') : '_(暂无)_';
|
|
119
|
+
return { text: `${before}## ${heading}\n\n${body}\n\n${after}`, found: true };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- check ----------
|
|
123
|
+
|
|
124
|
+
export function check(root) {
|
|
125
|
+
const problems = [];
|
|
126
|
+
const warnings = [];
|
|
127
|
+
|
|
128
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
129
|
+
console.log(`❌ 工作区根不存在:${root}`);
|
|
130
|
+
return 1;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const g = loadGraph(root);
|
|
134
|
+
if (!g.ok) {
|
|
135
|
+
console.log(`⚠️ ${g.reason}`);
|
|
136
|
+
console.log(' 没有图就没有关系可查 —— 契约见 templates.md 第三、七节。');
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const notes = collectNotes(root);
|
|
141
|
+
const hasNote = (name) => notes.has(name);
|
|
142
|
+
|
|
143
|
+
const fwd = new Map(); // from -> Set(to)
|
|
144
|
+
const bwd = new Map(); // to -> Set(from)
|
|
145
|
+
const addTo = (m, k, v) => {
|
|
146
|
+
if (!m.has(k)) m.set(k, new Set());
|
|
147
|
+
m.get(k).add(v);
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
g.edges.forEach((e, i) => {
|
|
151
|
+
const at = `edges[${i}]`;
|
|
152
|
+
const from = noteName(e && e.from);
|
|
153
|
+
const to = noteName(e && e.to);
|
|
154
|
+
const reason = e && typeof e.reason === 'string' ? e.reason.trim() : '';
|
|
155
|
+
|
|
156
|
+
if (!from || !to) {
|
|
157
|
+
problems.push(`${at}:缺 from 或 to`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (from === to) problems.push(`无自链 ✖ ${at}:${from} → ${to}`);
|
|
161
|
+
if (!hasNote(from)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${from}」`);
|
|
162
|
+
if (!hasNote(to)) problems.push(`无悬空 ✖ ${at}:找不到笔记「${to}」`);
|
|
163
|
+
if (!reason) problems.push(`${at}:缺 reason(每条边必须 ≤30 字理由)`);
|
|
164
|
+
else if ([...reason].length > 30) problems.push(`${at}:reason ${[...reason].length} 字 > 30(${reason})`);
|
|
165
|
+
|
|
166
|
+
addTo(fwd, from, to);
|
|
167
|
+
addTo(bwd, to, from);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// 参与图的笔记:只见于图中的那些才强校验(图允许分批,图外的笔记不报缺小节)
|
|
171
|
+
const involved = new Set([...fwd.keys(), ...bwd.keys()]);
|
|
172
|
+
|
|
173
|
+
for (const [name, path] of notes) {
|
|
174
|
+
const text = readIfPossible(path);
|
|
175
|
+
if (text === null) {
|
|
176
|
+
warnings.push(`读不了:${path}`);
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
const pre = sectionBullets(text, '前置知识');
|
|
180
|
+
const post = sectionBullets(text, '后置知识');
|
|
181
|
+
|
|
182
|
+
if (involved.has(name)) {
|
|
183
|
+
if (pre === null) warnings.push(`「${name}」缺 \`## 前置知识\` 小节`);
|
|
184
|
+
if (post === null) warnings.push(`「${name}」缺 \`## 后置知识\` 小节`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const [label, list] of [['前置知识', pre], ['后置知识', post]]) {
|
|
188
|
+
if (!list) continue;
|
|
189
|
+
const seen = new Set();
|
|
190
|
+
for (const n of list) {
|
|
191
|
+
if (seen.has(n)) problems.push(`同段无重复 ✖ 「${name}」\`## ${label}\` 里「${n}」出现两次`);
|
|
192
|
+
seen.add(n);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (pre && post) {
|
|
197
|
+
const both = [...new Set(pre.filter((n) => post.includes(n)))];
|
|
198
|
+
if (both.length) problems.push(`前后置互斥 ✖ 「${name}」同时出现在前置与后置:${both.join('、')}`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pre) {
|
|
202
|
+
for (const n of new Set(pre)) {
|
|
203
|
+
if (!(bwd.get(name) || new Set()).has(n)) {
|
|
204
|
+
problems.push(`双向自洽 ✖ 「${name}」的 \`## 前置知识\` 里有「${n}」,但图里没有这条边`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (post) {
|
|
209
|
+
for (const n of new Set(post)) {
|
|
210
|
+
if (!(fwd.get(name) || new Set()).has(n)) {
|
|
211
|
+
problems.push(`双向自洽 ✖ 「${name}」的 \`## 后置知识\` 里有「${n}」,但图里没有这条边`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// 图里有、正文里没有
|
|
218
|
+
for (const [from, tos] of fwd) {
|
|
219
|
+
for (const to of tos) {
|
|
220
|
+
const t1 = notes.has(from) ? readIfPossible(notes.get(from)) : null;
|
|
221
|
+
if (t1 !== null) {
|
|
222
|
+
const post = sectionBullets(t1, '后置知识') || [];
|
|
223
|
+
if (!post.includes(to)) problems.push(`双向自洽 ✖ 「${from}」的 \`## 后置知识\` 缺「${to}」(图里有这条边)`);
|
|
224
|
+
}
|
|
225
|
+
const t2 = notes.has(to) ? readIfPossible(notes.get(to)) : null;
|
|
226
|
+
if (t2 !== null) {
|
|
227
|
+
const pre = sectionBullets(t2, '前置知识') || [];
|
|
228
|
+
if (!pre.includes(from)) problems.push(`双向自洽 ✖ 「${to}」的 \`## 前置知识\` 缺「${from}」(图里有这条边)`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// 规则 6:同一篇的前置集合两两不可比(只收直接前置)
|
|
234
|
+
const reach = (start) => {
|
|
235
|
+
const seen = new Set([start]);
|
|
236
|
+
const stack = [...(fwd.get(start) || [])];
|
|
237
|
+
while (stack.length) {
|
|
238
|
+
const cur = stack.pop();
|
|
239
|
+
if (seen.has(cur)) continue;
|
|
240
|
+
seen.add(cur);
|
|
241
|
+
for (const t of fwd.get(cur) || []) if (!seen.has(t)) stack.push(t);
|
|
242
|
+
}
|
|
243
|
+
seen.delete(start);
|
|
244
|
+
return seen;
|
|
245
|
+
};
|
|
246
|
+
for (const name of involved) {
|
|
247
|
+
const path = notes.get(name);
|
|
248
|
+
if (!path) continue;
|
|
249
|
+
const list = sectionBullets(readIfPossible(path) || '', '前置知识');
|
|
250
|
+
if (!list) continue;
|
|
251
|
+
const uniq = [...new Set(list)];
|
|
252
|
+
for (let i = 0; i < uniq.length; i++) {
|
|
253
|
+
for (let j = i + 1; j < uniq.length; j++) {
|
|
254
|
+
const a = uniq[i];
|
|
255
|
+
const b = uniq[j];
|
|
256
|
+
if (reach(a).has(b)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${a}」已经是「${b}」的前置 —— 只收直接前置`);
|
|
257
|
+
else if (reach(b).has(a)) problems.push(`前置两两不可比 ✖ 「${name}」的前置里「${b}」已经是「${a}」的前置 —— 只收直接前置`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (g.data['用途'] === undefined) warnings.push('graph.json 缺「用途」字段');
|
|
263
|
+
if (g.data['规则'] === undefined) warnings.push('graph.json 缺「规则」字段');
|
|
264
|
+
const cov = g.data.coverage;
|
|
265
|
+
if (cov && typeof cov === 'object') {
|
|
266
|
+
const done = Object.values(cov).flatMap((v) => (v && Array.isArray(v.done) ? v.done : []));
|
|
267
|
+
warnings.length; // no-op,保持结构清晰
|
|
268
|
+
if (!done.length) warnings.push('coverage 里还没有任何 done —— 图可能还没开始建');
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
console.log(`图:${relative(process.cwd(), g.path) || g.path}`);
|
|
272
|
+
console.log(`边 ${g.edges.length} 条 · 知识点 ${notes.size} 篇 · 参与图的 ${involved.size} 篇`);
|
|
273
|
+
if (warnings.length) {
|
|
274
|
+
console.log('');
|
|
275
|
+
for (const w of warnings) console.log(`⚠️ ${w}`);
|
|
276
|
+
}
|
|
277
|
+
if (problems.length) {
|
|
278
|
+
console.log('');
|
|
279
|
+
for (const p of problems) console.log(`❌ ${p}`);
|
|
280
|
+
console.log('');
|
|
281
|
+
console.log(`未过:${problems.length} 个问题。`);
|
|
282
|
+
return 1;
|
|
283
|
+
}
|
|
284
|
+
console.log('');
|
|
285
|
+
console.log('✅ 六项校验全过:双向自洽 / 无自链 / 无悬空 / 同段无重复 / 前后置互斥 / 前置两两不可比。');
|
|
286
|
+
return 0;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---------- backlinks ----------
|
|
290
|
+
|
|
291
|
+
function backlinks(root, write) {
|
|
292
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
293
|
+
console.log(`❌ 工作区根不存在:${root}`);
|
|
294
|
+
return 2;
|
|
295
|
+
}
|
|
296
|
+
const g = loadGraph(root);
|
|
297
|
+
if (!g.ok) {
|
|
298
|
+
console.log(`❌ ${g.reason}`);
|
|
299
|
+
return 1;
|
|
300
|
+
}
|
|
301
|
+
const notes = collectNotes(root);
|
|
302
|
+
|
|
303
|
+
const preOf = new Map();
|
|
304
|
+
const postOf = new Map();
|
|
305
|
+
for (const e of g.edges) {
|
|
306
|
+
const from = noteName(e && e.from);
|
|
307
|
+
const to = noteName(e && e.to);
|
|
308
|
+
const reason = e && typeof e.reason === 'string' ? e.reason.trim() : '';
|
|
309
|
+
if (!from || !to) continue;
|
|
310
|
+
if (!postOf.has(from)) postOf.set(from, []);
|
|
311
|
+
postOf.get(from).push(to);
|
|
312
|
+
if (!preOf.has(to)) preOf.set(to, []);
|
|
313
|
+
preOf.get(to).push({ from, reason });
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const rel = (p) => relative(root, p).split(sep).join('/');
|
|
317
|
+
let changed = 0;
|
|
318
|
+
let touched = 0;
|
|
319
|
+
|
|
320
|
+
for (const [name, path] of notes) {
|
|
321
|
+
const pre = (preOf.get(name) || []).filter((x) => notes.has(x.from));
|
|
322
|
+
const post = [...new Set(postOf.get(name) || [])].filter((x) => notes.has(x));
|
|
323
|
+
if (!pre.length && !post.length) continue;
|
|
324
|
+
const text = readIfPossible(path);
|
|
325
|
+
if (text === null) continue;
|
|
326
|
+
const preBullets = pre.map((x) => {
|
|
327
|
+
const target = notes.get(x.from);
|
|
328
|
+
const link = target ? `[[${rel(target)}|${x.from}]]` : `[[${x.from}]]`;
|
|
329
|
+
return `- ${link}${x.reason ? ` —— ${x.reason}` : ''}`;
|
|
330
|
+
});
|
|
331
|
+
const postBullets = post.map((x) => {
|
|
332
|
+
const target = notes.get(x);
|
|
333
|
+
return target ? `- [[${rel(target)}|${x}]]` : `- [[${x}]]`;
|
|
334
|
+
});
|
|
335
|
+
const a = replaceSection(text, '前置知识', preBullets);
|
|
336
|
+
const b = replaceSection(a.text, '后置知识', postBullets);
|
|
337
|
+
if (!a.found && !b.found) continue;
|
|
338
|
+
touched++;
|
|
339
|
+
if (b.text !== text) {
|
|
340
|
+
changed++;
|
|
341
|
+
if (write) {
|
|
342
|
+
try {
|
|
343
|
+
writeFileSync(path, b.text, 'utf8');
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.log(`❌ 写不了 ${path}:${err.message}`);
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
console.log(`${write ? '已写回' : '预览(未写盘)'}:${touched} 篇有两段,${changed} 篇内容有变化。`);
|
|
353
|
+
if (!write && changed) console.log('加 --write 才会真正写盘(只动 `## 前置知识` / `## 后置知识` 两个小节)。');
|
|
354
|
+
return 0;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// ---------- sort(可选:本机装了 Miyo 才用) ----------
|
|
358
|
+
|
|
359
|
+
async function miyoSort(root, noteArg, folderArg, limit) {
|
|
360
|
+
const note = noteName(noteArg);
|
|
361
|
+
const notes = collectNotes(root);
|
|
362
|
+
const path = notes.get(note);
|
|
363
|
+
if (!path) {
|
|
364
|
+
console.log(`❌ 找不到笔记「${note}」`);
|
|
365
|
+
return 2;
|
|
366
|
+
}
|
|
367
|
+
const folderName = folderArg || basename(dirname(root));
|
|
368
|
+
const relPath = relative(dirname(root), path).split(sep).join('\\');
|
|
369
|
+
const filePath = `${folderName}/${relPath}`;
|
|
370
|
+
|
|
371
|
+
let res;
|
|
372
|
+
try {
|
|
373
|
+
res = await fetch(MIYO_ENDPOINT, {
|
|
374
|
+
method: 'POST',
|
|
375
|
+
headers: { 'Content-Type': 'application/json' },
|
|
376
|
+
body: JSON.stringify({ file_path: filePath, limit }),
|
|
377
|
+
});
|
|
378
|
+
} catch (err) {
|
|
379
|
+
console.log(`ℹ️ 连不上 Miyo(${MIYO_ENDPOINT}):${err.message}`);
|
|
380
|
+
console.log(' 没装 Miyo 就跳过排序 —— 发布版不依赖它。');
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
if (!res.ok) {
|
|
384
|
+
console.log(`ℹ️ Miyo 返回 ${res.status}:${(await res.text()).slice(0, 200)}`);
|
|
385
|
+
console.log(` 用的 file_path = ${filePath}`);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
const data = await res.json().catch(() => null);
|
|
389
|
+
const results = data && Array.isArray(data.results) ? data.results : [];
|
|
390
|
+
const score = new Map(results.map((r) => [noteName(r.path), r.score]));
|
|
391
|
+
|
|
392
|
+
console.log(`Miyo related:${filePath} → ${results.length} 条`);
|
|
393
|
+
console.log('');
|
|
394
|
+
const text = readIfPossible(path) || '';
|
|
395
|
+
for (const heading of ['前置知识', '后置知识']) {
|
|
396
|
+
const list = sectionBullets(text, heading) || [];
|
|
397
|
+
const ranked = [...list].sort((a, b) => (score.has(b) ? score.get(b) : -1) - (score.has(a) ? score.get(a) : -1));
|
|
398
|
+
console.log(`## ${heading}(按 Miyo 相关度排序,仅供参考;定序者是知识深度)`);
|
|
399
|
+
for (const n of ranked) {
|
|
400
|
+
const s = score.has(n) ? score.get(n).toFixed(3) : '—';
|
|
401
|
+
console.log(`- [[${n}]] ${s}`);
|
|
402
|
+
}
|
|
403
|
+
console.log('');
|
|
404
|
+
}
|
|
405
|
+
return 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---------- main ----------
|
|
409
|
+
|
|
410
|
+
function main() {
|
|
411
|
+
const argv = process.argv.slice(2);
|
|
412
|
+
const cmd = argv[0];
|
|
413
|
+
const rest = argv.slice(1).filter((a) => !a.startsWith('--'));
|
|
414
|
+
const flags = new Set(argv.filter((a) => a.startsWith('--')).map((a) => a.split('=')[0]));
|
|
415
|
+
const opt = (name, dflt) => {
|
|
416
|
+
const hit = argv.find((a) => a.startsWith(name + '='));
|
|
417
|
+
if (hit) return hit.split('=').slice(1).join('=');
|
|
418
|
+
const i = argv.indexOf(name);
|
|
419
|
+
if (i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--')) return argv[i + 1];
|
|
420
|
+
return dflt;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
if (!cmd || !['check', 'backlinks', 'sort'].includes(cmd)) {
|
|
424
|
+
console.log('用法:');
|
|
425
|
+
console.log(' node graph.mjs check <工作区根>');
|
|
426
|
+
console.log(' node graph.mjs backlinks <工作区根> [--write]');
|
|
427
|
+
console.log(' node graph.mjs sort <工作区根> --note <笔记名> [--folder <folder 名>] [--limit N]');
|
|
428
|
+
return 2;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const root = resolve(rest[0] || '.');
|
|
432
|
+
if (cmd === 'check') return check(root);
|
|
433
|
+
if (cmd === 'backlinks') return backlinks(root, flags.has('--write'));
|
|
434
|
+
const limit = Number(opt('--limit', '20')) || 20;
|
|
435
|
+
return miyoSort(root, opt('--note', ''), opt('--folder', ''), limit);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// 直接跑才执行;被 scan.mjs import 时不执行(只借它的 check 函数)
|
|
439
|
+
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
440
|
+
if (invokedDirectly) {
|
|
441
|
+
try {
|
|
442
|
+
const code = await Promise.resolve(main());
|
|
443
|
+
process.exit(typeof code === 'number' ? code : 0);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
console.log('❌ graph.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
|
|
446
|
+
process.exit(1);
|
|
447
|
+
}
|
|
448
|
+
}
|