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.
Files changed (28) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +1 -1
  3. package/skills/mastery-loop/SKILL.md +22 -21
  4. package/skills/mastery-loop/exam-templates.md +102 -0
  5. package/skills/mastery-loop/scripts/_lib.mjs +408 -0
  6. package/skills/mastery-loop/scripts/aggregate.mjs +155 -0
  7. package/skills/mastery-loop/scripts/graph.mjs +448 -0
  8. package/skills/mastery-loop/scripts/ledger.mjs +223 -0
  9. package/skills/mastery-loop/scripts/rewrite-links.mjs +81 -0
  10. package/skills/mastery-loop/scripts/scan.mjs +276 -0
  11. package/skills/mastery-loop/scripts/validate.mjs +115 -59
  12. package/skills/mastery-loop/templates.md +210 -62
  13. package/skills/mastery-loop-setup/SKILL.md +105 -76
  14. package/skills/mastery-loop-setup/seeds/config.json +39 -0
  15. package/skills/mastery-loop-setup/seeds/error-taxonomy.json +4 -0
  16. package/skills/mastery-loop-setup/seeds/errors.jsonl +1 -0
  17. package/skills/mastery-loop-setup/seeds/graph.json +13 -0
  18. package/skills/mastery-loop-setup/seeds/ledger.jsonl +2 -0
  19. package/skills/mastery-loop-setup/seeds/moc.md +15 -0
  20. package/skills/mastery-loop-setup/seeds/note.md +33 -0
  21. package/skills/mastery-loop-setup/seeds/paper-format.json +15 -0
  22. package/skills/mastery-loop-setup/seeds/state.jsonl +2 -0
  23. package/skills/mastery-loop-setup/seeds/config.md +0 -34
  24. package/skills/mastery-loop-setup/seeds/error-taxonomy.md +0 -17
  25. package/skills/mastery-loop-setup/seeds/errors.md +0 -48
  26. package/skills/mastery-loop-setup/seeds/index.md +0 -25
  27. package/skills/mastery-loop-setup/seeds/ledger.md +0 -26
  28. package/skills/mastery-loop-setup/seeds/paper-format.md +0 -30
@@ -0,0 +1,223 @@
1
+ #!/usr/bin/env node
2
+ // 账本脚本
3
+ //
4
+ // 用途:本脚本管 sys/ledger.jsonl(复习账本,真值)—— 算达标、按 60/30/10 划范围清单、把批改结果写回。
5
+ // 契约正文:../templates.md(第十一节)
6
+ //
7
+ // 用法:
8
+ // node ledger.mjs stats <工作区根> [--scope 深度学习/L3] [--target 0.9]
9
+ // node ledger.mjs pick <工作区根> [--scope …] [--target 0.9] [--n 20]
10
+ // node ledger.mjs write <工作区根> <updates.jsonl>
11
+ //
12
+ // write 的输入:一行一条 {"知识点":"梯度","做对":1,"总共出过":1,"是否学习过":true,"最后复习日期":"2026-10-15"}
13
+ // 其中 做对 / 总共出过 按**增量**累加;熟练程度自动 = 做对 / 总共出过。
14
+
15
+ import { existsSync, statSync } from 'node:fs';
16
+ import { join, resolve } from 'node:path';
17
+ import { SYS, NOTES, readJson, readJsonl, writeJsonl, collectNotes, readNote, readIfPossible } from './_lib.mjs';
18
+
19
+ function load(root) {
20
+ const cfg = readJson(join(root, SYS, 'config.json'));
21
+ const led = readJsonl(join(root, SYS, 'ledger.jsonl'));
22
+ if (!led.ok) return { err: led.reason };
23
+ const notes = new Map();
24
+ for (const [name, p] of collectNotes(root)) {
25
+ const n = readNote(root, name, p, cfg.ok ? cfg.data : null);
26
+ notes.set(name, n.分类);
27
+ }
28
+ return { cfg: cfg.ok ? cfg.data : null, header: led.header, rows: led.rows, classes: notes };
29
+ }
30
+
31
+ function inScope(classes, scopeParts) {
32
+ if (!scopeParts) return true;
33
+ const list = classes || [];
34
+ return list.some((parts) => {
35
+ if (parts.length < scopeParts.length) return false;
36
+ return scopeParts.every((s, i) => parts[i] === s);
37
+ });
38
+ }
39
+
40
+ function proficiency(row) {
41
+ const done = Number(row['总共出过'] || 0);
42
+ const right = Number(row['做对'] || 0);
43
+ if (!done) return null;
44
+ return Math.round((right / done) * 1000) / 1000;
45
+ }
46
+
47
+ function flag(row, target) {
48
+ const done = Number(row['总共出过'] || 0);
49
+ const p = proficiency(row);
50
+ if (done < 5) return '样本不足';
51
+ if (p !== null && p >= target) return '达标';
52
+ return '未达标';
53
+ }
54
+
55
+ function cmdStats(root, scopeParts, target) {
56
+ const L = load(root);
57
+ if (L.err) {
58
+ console.log(`❌ ${L.err}`);
59
+ return 1;
60
+ }
61
+ const rows = L.rows.filter((r) => inScope(L.classes.get(r['知识点']), scopeParts));
62
+ console.log(`范围:${scopeParts ? scopeParts.join('/') : '全库'} · 知识点 ${rows.length} 个 · 目标正确率 ${target}`);
63
+ console.log('');
64
+ let ok = 0;
65
+ let thin = 0;
66
+ let bad = 0;
67
+ for (const r of rows) {
68
+ const f = flag(r, target);
69
+ if (f === '达标') ok++;
70
+ else if (f === '样本不足') thin++;
71
+ else bad++;
72
+ const p = proficiency(r);
73
+ console.log(
74
+ `- ${r['知识点']} · 出过 ${r['总共出过'] ?? 0} · 做对 ${r['做对'] ?? 0} · 熟练程度 ${p === null ? 'NULL' : p} · ${f}` +
75
+ (r['最后复习日期'] ? ` · 最后 ${r['最后复习日期']}` : '')
76
+ );
77
+ }
78
+ console.log('');
79
+ console.log(`达标 ${ok} · 未达标 ${bad} · 样本不足 ${thin}`);
80
+ return 0;
81
+ }
82
+
83
+ function cmdPick(root, scopeParts, target, n) {
84
+ const L = load(root);
85
+ if (L.err) {
86
+ console.log(`❌ ${L.err}`);
87
+ return 1;
88
+ }
89
+ const rows = L.rows.filter((r) => inScope(L.classes.get(r['知识点']), scopeParts));
90
+ const weakest = [...rows].sort((a, b) => {
91
+ const pa = proficiency(a);
92
+ const pb = proficiency(b);
93
+ if (pa === null && pb === null) return Number(a['总共出过'] || 0) - Number(b['总共出过'] || 0);
94
+ if (pa === null) return -1;
95
+ if (pb === null) return 1;
96
+ return pa - pb;
97
+ });
98
+ const never = rows.filter((r) => r['是否学习过'] !== true);
99
+ const oldest = [...rows]
100
+ .filter((r) => r['最后复习日期'])
101
+ .sort((a, b) => String(a['最后复习日期']).localeCompare(String(b['最后复习日期'])));
102
+
103
+ const total = n || rows.length;
104
+ const nA = Math.round(total * 0.6);
105
+ const nB = Math.round(total * 0.3);
106
+ const nC = Math.max(0, total - nA - nB);
107
+
108
+ const chosen = [];
109
+ const seen = new Set();
110
+ const take = (list, k, label) => {
111
+ for (const r of list) {
112
+ if (chosen.length >= total) return;
113
+ if (k <= 0) return;
114
+ const key = r['知识点'];
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ chosen.push({ ...r, bucket: label });
118
+ k--;
119
+ }
120
+ };
121
+ take(weakest, nA, '60% 熟练度最低');
122
+ take(never, nB, '30% 没学过');
123
+ take(oldest.length ? oldest : weakest, nC, '10% 最久没碰');
124
+ take(weakest, total, '补齐');
125
+
126
+ console.log(`本次范围:${scopeParts ? scopeParts.join('/') : '全库'} · 目标正确率 ${target} · 题量 ${total}`);
127
+ console.log('');
128
+ let last = '';
129
+ for (const r of chosen) {
130
+ if (r.bucket !== last) {
131
+ console.log(`## ${r.bucket}`);
132
+ last = r.bucket;
133
+ }
134
+ const p = proficiency(r);
135
+ console.log(`- ${r['知识点']} (出过 ${r['总共出过'] ?? 0} · 做对 ${r['做对'] ?? 0} · 熟练程度 ${p === null ? 'NULL' : p})`);
136
+ }
137
+ return 0;
138
+ }
139
+
140
+ function cmdWrite(root, updatesPath) {
141
+ const L = load(root);
142
+ if (L.err) {
143
+ console.log(`❌ ${L.err}`);
144
+ return 1;
145
+ }
146
+ const text = readIfPossible(resolve(updatesPath));
147
+ if (text === null) {
148
+ console.log(`❌ 读不了更新文件:${updatesPath}`);
149
+ return 1;
150
+ }
151
+ const updates = text
152
+ .split(/\r?\n/)
153
+ .filter((l) => l.trim())
154
+ .map((l) => {
155
+ try {
156
+ return JSON.parse(l);
157
+ } catch {
158
+ return null;
159
+ }
160
+ })
161
+ .filter(Boolean);
162
+
163
+ const byName = new Map(L.rows.map((r) => [r['知识点'], r]));
164
+ let changed = 0;
165
+ for (const u of updates) {
166
+ const row = byName.get(u['知识点']);
167
+ if (!row) {
168
+ console.log(`⚠️ 账本里没有「${u['知识点']}」(跳过)`);
169
+ continue;
170
+ }
171
+ if (u['做对'] !== undefined) row['做对'] = Number(row['做对'] || 0) + Number(u['做对']);
172
+ if (u['总共出过'] !== undefined) row['总共出过'] = Number(row['总共出过'] || 0) + Number(u['总共出过']);
173
+ if (u['是否学习过'] !== undefined) row['是否学习过'] = u['是否学习过'] === true;
174
+ if (u['最后复习日期'] !== undefined) row['最后复习日期'] = u['最后复习日期'];
175
+ const p = proficiency(row);
176
+ row['熟练程度'] = p === null ? null : p;
177
+ changed++;
178
+ }
179
+
180
+ writeJsonl(join(root, SYS, 'ledger.jsonl'), L.header || { kind: 'header', '用途': '本文件是复习账本:逐条记每个知识点的复习统计。' }, L.rows);
181
+ console.log(`已写回 ${changed} 行(共 ${L.rows.length} 行)。`);
182
+ return 0;
183
+ }
184
+
185
+ function main() {
186
+ const argv = process.argv.slice(2);
187
+ const cmd = argv[0];
188
+ const rest = argv.slice(1).filter((a) => !a.startsWith('--'));
189
+ const opt = (name, dflt) => {
190
+ const i = argv.indexOf(name);
191
+ return i !== -1 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : dflt;
192
+ };
193
+
194
+ if (!['stats', 'pick', 'write'].includes(cmd)) {
195
+ console.log('用法:');
196
+ console.log(' node ledger.mjs stats <工作区根> [--scope 深度学习/L3] [--target 0.9]');
197
+ console.log(' node ledger.mjs pick <工作区根> [--scope …] [--target 0.9] [--n 20]');
198
+ console.log(' node ledger.mjs write <工作区根> <updates.jsonl>');
199
+ return 2;
200
+ }
201
+
202
+ const root = resolve(rest[0] || '.');
203
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
204
+ console.log(`❌ 工作区根不存在:${root}`);
205
+ return 1;
206
+ }
207
+ const scope = opt('--scope', '')
208
+ .split('/')
209
+ .map((s) => s.trim())
210
+ .filter(Boolean);
211
+ const target = Number(opt('--target', '0.9')) || 0.9;
212
+
213
+ if (cmd === 'stats') return cmdStats(root, scope.length ? scope : null, target);
214
+ if (cmd === 'pick') return cmdPick(root, scope.length ? scope : null, target, Number(opt('--n', '0')) || 0);
215
+ return cmdWrite(root, rest[1]);
216
+ }
217
+
218
+ try {
219
+ process.exit(main());
220
+ } catch (err) {
221
+ console.log('❌ ledger.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
222
+ process.exit(1);
223
+ }
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // 重写链接脚本
3
+ //
4
+ // 用途:本脚本把「路径变了但链接没跟着改」的 wikilink 修回当前路径(名字唯一才敢改,重名的一律留给人)。
5
+ // 契约正文:../templates.md(第二、七节)
6
+ //
7
+ // 用法:
8
+ // node rewrite-links.mjs <工作区根> # 只列建议(不写盘)
9
+ // node rewrite-links.mjs <工作区根> --apply # 真写
10
+
11
+ import { existsSync, statSync } from 'node:fs';
12
+ import { resolve } from 'node:path';
13
+ import { readIfPossible, writeText, walkMarkdown, parseLinks } from './_lib.mjs';
14
+
15
+ function main() {
16
+ const argv = process.argv.slice(2);
17
+ const root = resolve(argv.find((a) => !a.startsWith('--')) || '.');
18
+ const apply = argv.includes('--apply');
19
+
20
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
21
+ console.log(`❌ 工作区根不存在:${root}`);
22
+ return 2;
23
+ }
24
+
25
+ const files = walkMarkdown(root, '.');
26
+ const rels = files.map((p) => p.slice(root.length + 1).split('\\').join('/'));
27
+ const relSet = new Set(rels);
28
+ const byBase = new Map();
29
+ for (const rel of rels) {
30
+ const base = rel.split('/').pop().replace(/\.md$/i, '');
31
+ if (!byBase.has(base)) byBase.set(base, []);
32
+ byBase.get(base).push(rel);
33
+ }
34
+
35
+ let planned = 0;
36
+ let written = 0;
37
+
38
+ for (let i = 0; i < files.length; i++) {
39
+ const path = files[i];
40
+ const rel = rels[i];
41
+ const text = readIfPossible(path);
42
+ if (text === null) continue;
43
+
44
+ let next = text;
45
+ let touched = false;
46
+ for (const link of parseLinks(text)) {
47
+ const t = link.target.replace(/^\.\//, '');
48
+ if (relSet.has(t) || relSet.has(t + '.md')) continue;
49
+ const base = t.split('/').pop().replace(/\.md$/i, '');
50
+ const hits = byBase.get(base) || [];
51
+ if (hits.length !== 1) continue;
52
+ const fixed = hits[0].endsWith('.md') ? hits[0] : hits[0] + '.md';
53
+ const before = link.alias ? `[[${link.target}|${link.alias}]]` : `[[${link.target}]]`;
54
+ const after = link.alias ? `[[${fixed}|${link.alias}]]` : `[[${fixed}]]`;
55
+ if (!next.includes(before)) continue;
56
+ next = next.replace(before, after);
57
+ touched = true;
58
+ planned++;
59
+ console.log(`${apply ? '改写' : '建议'}:${rel}\n ${before} → ${after}`);
60
+ }
61
+ if (touched && apply && next !== text) {
62
+ writeText(path, next);
63
+ written++;
64
+ }
65
+ }
66
+
67
+ console.log('');
68
+ if (!planned) {
69
+ console.log('✅ 没有需要重写的链接。');
70
+ return 0;
71
+ }
72
+ console.log(apply ? `已写 ${written} 个文件、改 ${planned} 处链接。` : `共 ${planned} 处可自动修(加 --apply 才写盘;重名的链接已跳过)。`);
73
+ return 0;
74
+ }
75
+
76
+ try {
77
+ process.exit(main());
78
+ } catch (err) {
79
+ console.log('❌ rewrite-links.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
80
+ process.exit(1);
81
+ }
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ // 扫库脚本
3
+ //
4
+ // 用途:本脚本是健康检查 —— 查链接、查分类与配置是否对得上、查图与笔记是否一致、查账本引用;
5
+ // 并读 sys/state.jsonl 按内容哈希跳过没变过的文件(--update-state 写回哈希)。
6
+ // 契约正文:../templates.md(第七节)
7
+ //
8
+ // 用法:node scan.mjs <工作区根> [--update-state]
9
+
10
+ import { existsSync, statSync, readdirSync, openSync, closeSync, unlinkSync } from 'node:fs';
11
+ import { spawnSync } from 'node:child_process';
12
+ import { join, resolve } from 'node:path';
13
+ import {
14
+ SYS, readJson, readJsonl, writeJsonl, collectNotes, readNote, readIfPossible,
15
+ walkMarkdown, parseLinks, sha1,
16
+ } from './_lib.mjs';
17
+ import * as checkGraphModule from './graph.mjs';
18
+
19
+ const EXPECTED_SCHEMA = '0.2.0';
20
+
21
+ function main() {
22
+ const argv = process.argv.slice(2);
23
+ const root = resolve(argv.find((a) => !a.startsWith('--')) || '.');
24
+ const updateState = argv.includes('--update-state');
25
+
26
+ if (!existsSync(root) || !statSync(root).isDirectory()) {
27
+ console.log(`❌ 工作区根不存在:${root}`);
28
+ return 2;
29
+ }
30
+
31
+ const problems = [];
32
+ const notes = [];
33
+
34
+ // ---------- 变化检测:**有 git 看 git**,没有 git 才用哈希兜底 ----------
35
+ // 注意:沙箱里**不能用管道**抓子进程输出(EPERM),所以把 stdout/stderr 重定向到文件描述符再读回来。
36
+ const TMPGIT = join(root, SYS, '.git-out.tmp');
37
+ const git = (args) => {
38
+ let fd = null;
39
+ try {
40
+ fd = openSync(TMPGIT, 'w');
41
+ const r = spawnSync('git', ['-C', root, '-c', 'core.quotePath=false', ...args], { stdio: ['ignore', fd, fd] });
42
+ closeSync(fd);
43
+ fd = null;
44
+ if (r.error || r.status !== 0) return null;
45
+ return readIfPossible(TMPGIT) || '';
46
+ } catch {
47
+ return null;
48
+ } finally {
49
+ if (fd !== null) {
50
+ try {
51
+ closeSync(fd);
52
+ } catch {}
53
+ }
54
+ try {
55
+ unlinkSync(TMPGIT);
56
+ } catch {}
57
+ }
58
+ };
59
+ const gitOk = (git(['rev-parse', '--is-inside-work-tree']) || '').trim() === 'true';
60
+
61
+ const st = readJsonl(join(root, SYS, 'state.jsonl'));
62
+ const stRows = st.ok ? st.rows : [];
63
+ const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
64
+
65
+ const mdFiles = walkMarkdown(root, join('kb'));
66
+ let stateRows = [];
67
+ let changedFiles = [];
68
+ let unchanged = 0;
69
+ let mode = 'hash';
70
+
71
+ if (gitOk) {
72
+ mode = 'git';
73
+ const head = (git(['rev-parse', 'HEAD']) || '').trim();
74
+ const mark = stRows.find((r) => r && r.mode === 'git');
75
+ const last = mark && mark.last ? mark.last : '';
76
+
77
+ const inKb = (p) => p.startsWith('kb/') && /\.md$/i.test(p);
78
+ const set = new Set();
79
+
80
+ // ① 未提交的变化
81
+ const por = git(['status', '--porcelain']);
82
+ if (por !== null) {
83
+ for (const line of por.split(/\r?\n/)) {
84
+ if (!line.trim()) continue;
85
+ let p = line.slice(3).trim();
86
+ const arrow = p.indexOf(' -> ');
87
+ if (arrow !== -1) p = p.slice(arrow + 4);
88
+ p = p.replace(/^"|"$/g, '').split('\\').join('/');
89
+ if (inKb(p)) set.add(p);
90
+ }
91
+ }
92
+ // ② 上次检查之后提交过的变化
93
+ if (last && head) {
94
+ const diff = git(['diff', '--name-only', `${last}..HEAD`]);
95
+ if (diff !== null) {
96
+ for (const p of diff.split(/\r?\n/)) {
97
+ const norm = p.trim().split('\\').join('/');
98
+ if (inKb(norm)) set.add(norm);
99
+ }
100
+ }
101
+ } else if (!last) {
102
+ for (const p of mdFiles) set.add(p.slice(root.length + 1).split('\\').join('/'));
103
+ }
104
+
105
+ changedFiles = [...set];
106
+ const total = mdFiles.length;
107
+ unchanged = Math.max(0, total - changedFiles.length);
108
+ stateRows = [{ mode: 'git', last: head, schema: EXPECTED_SCHEMA, checked: now }];
109
+ } else {
110
+ const prev = new Map(stRows.map((r) => [r.path, r.hash]));
111
+ for (const p of mdFiles) {
112
+ const rel = p.slice(root.length + 1).split('\\').join('/');
113
+ const h = sha1(readIfPossible(p) || '');
114
+ stateRows.push({ path: rel, hash: h, schema: EXPECTED_SCHEMA, checked: now });
115
+ if (prev.get(rel) === h) unchanged++;
116
+ else changedFiles.push(rel);
117
+ }
118
+ }
119
+
120
+ // ---------- ①② 链接(在**所有文件**里解析:PDF 之类也要算存在) ----------
121
+ const allFiles = walkMarkdown(root, '.');
122
+ const allRel = new Set();
123
+ const allBase = new Map();
124
+ const collect = (dirRel, depth) => {
125
+ if (depth > 5) return;
126
+ let entries = [];
127
+ try {
128
+ entries = readdirSync(join(root, dirRel), { withFileTypes: true });
129
+ } catch {
130
+ return;
131
+ }
132
+ for (const e of entries) {
133
+ if (e.name.startsWith('.')) continue;
134
+ const rel = dirRel === '.' ? e.name : join(dirRel, e.name);
135
+ if (e.isDirectory()) {
136
+ collect(rel, depth + 1);
137
+ continue;
138
+ }
139
+ const norm = rel.split('\\').join('/');
140
+ allRel.add(norm);
141
+ const base = norm.split('/').pop().replace(/\.md$/i, '');
142
+ if (!allBase.has(base)) allBase.set(base, []);
143
+ allBase.get(base).push(norm);
144
+ }
145
+ };
146
+ collect('.', 0);
147
+
148
+ const normTarget = (t) => t.replace(/^\.\//, '').split('\\').join('/');
149
+
150
+ for (const p of allFiles) {
151
+ const rel = p.slice(root.length + 1).split('\\').join('/');
152
+ const text = readIfPossible(p) || '';
153
+ for (const link of parseLinks(text)) {
154
+ const t = normTarget(link.target);
155
+ if (allRel.has(t) || allRel.has(t + '.md')) continue;
156
+ const base = t.split('/').pop().replace(/\.md$/i, '');
157
+ const hits = allBase.get(base) || [];
158
+ if (hits.length === 1) {
159
+ problems.push(`② 路径与目录不符:\`${rel}\` 里的 [[${link.target}]] → 现在它在 \`${hits[0]}\`(跑 rewrite-links.mjs 修)`);
160
+ } else if (hits.length > 1) {
161
+ problems.push(`② 路径与目录不符:\`${rel}\` 里的 [[${link.target}]] → 同名有 ${hits.length} 处,得人来定`);
162
+ } else {
163
+ problems.push(`① 悬空链接:\`${rel}\` 里的 [[${link.target}]] 找不到目标`);
164
+ }
165
+ }
166
+ }
167
+
168
+ // ---------- ③ 分类与配置 ----------
169
+ const c = readJson(join(root, SYS, 'config.json'));
170
+ if (!c.ok) {
171
+ problems.push(`③ ${c.reason}`);
172
+ } else {
173
+ const cfg = c.data;
174
+ const field = cfg?.['分类']?.['字段'] || '分类';
175
+ const sep = cfg?.['分类']?.['分隔符'] || '/';
176
+ const levels = cfg?.['分类']?.['层级'] || [];
177
+ const values = cfg?.['分类']?.['类值'] || {};
178
+ if (cfg['schema_version'] && cfg['schema_version'] !== EXPECTED_SCHEMA) {
179
+ problems.push(`③ config.schema_version = ${cfg['schema_version']},本套脚本按 ${EXPECTED_SCHEMA} 跑 —— 需要迁移`);
180
+ }
181
+ for (const [name, p] of collectNotes(root)) {
182
+ const n = readNote(root, name, p, cfg);
183
+ notes.push(n);
184
+ if (!n.分类.length) {
185
+ problems.push(`③ 「${name}」的 \`${field}\` 是空的 —— 生成知识点时就该附上分类`);
186
+ continue;
187
+ }
188
+ for (const parts of n.分类) {
189
+ if (levels.length && parts.length !== levels.length) {
190
+ problems.push(`③ 「${name}」的分类 \`${parts.join(sep)}\` 只有 ${parts.length} 层,配置里是 ${levels.length} 层`);
191
+ }
192
+ parts.forEach((v, i) => {
193
+ const level = levels[i];
194
+ if (!level) return;
195
+ const list = (values[level] || []).map((x) => x['值'] ?? x);
196
+ if (list.length && !list.includes(v)) {
197
+ problems.push(`③ 「${name}」用的类值 \`${v}\` 没在 config 的「${level}」里声明`);
198
+ }
199
+ });
200
+ }
201
+ }
202
+ }
203
+
204
+ // ---------- ④ 图与笔记一致(同进程调用 graph.mjs 的 check;沙箱下不能起子进程) ----------
205
+ const captured = [];
206
+ const origLog = console.log;
207
+ console.log = (...a) => captured.push(a.join(' '));
208
+ let gcode = 1;
209
+ try {
210
+ gcode = checkGraphModule.check(root);
211
+ } catch (err) {
212
+ captured.push('❌ graph check 抛错:' + (err && err.message ? err.message : String(err)));
213
+ } finally {
214
+ console.log = origLog;
215
+ }
216
+ if (gcode !== 0) {
217
+ const lines = captured.filter((l) => l.startsWith('❌'));
218
+ if (lines.length) for (const l of lines) problems.push(`④ ${l}`);
219
+ else problems.push(`④ 图校验没过:${captured.find((l) => l.trim()) || '(无输出)'}`);
220
+ }
221
+
222
+ // ---------- ⑤ 账本 / 错误原因引用 ----------
223
+ const known = new Set(notes.map((n) => n.name));
224
+ const led = readJsonl(join(root, SYS, 'ledger.jsonl'));
225
+ if (led.ok) {
226
+ for (const r of led.rows) {
227
+ const name = r['知识点'];
228
+ if (name && !known.has(name)) problems.push(`⑤ 账本里的「${name}」找不到对应知识点`);
229
+ }
230
+ }
231
+ const errs = readJsonl(join(root, SYS, 'errors.jsonl'));
232
+ if (errs.ok) {
233
+ for (const r of errs.rows) {
234
+ const name = r['知识点'];
235
+ if (name && !known.has(name)) problems.push(`⑤ 错误原因里的「${name}」找不到对应知识点`);
236
+ }
237
+ }
238
+
239
+ // ---------- 输出 ----------
240
+ console.log(`扫库:${root}`);
241
+ console.log(
242
+ `文件 ${mdFiles.length} 个 · 判定方式:${mode === 'git' ? 'git' : '哈希(这个库没有 git)'} · 没变过的 ${unchanged} 个${changedFiles.length ? ` · 变过的 ${changedFiles.length} 个` : ''} · 知识点 ${notes.length} 篇`
243
+ );
244
+ if (changedFiles.length) {
245
+ console.log('');
246
+ for (const f of changedFiles.slice(0, 20)) console.log(`· 变过:${f}${changedFiles.length > 20 ? '' : ''}`);
247
+ if (changedFiles.length > 20) console.log(`· …还有 ${changedFiles.length - 20} 个`);
248
+ }
249
+
250
+ if (updateState) {
251
+ writeJsonl(
252
+ join(root, SYS, 'state.jsonl'),
253
+ { kind: 'header', '用途': '本文件是状态表(变化检测):有 git 时只记上次检查到的提交号;没有 git 时逐文件记内容哈希。管「要不要重看」,不管「发生过什么」(那是 git / changes.jsonl)。' },
254
+ stateRows
255
+ );
256
+ console.log('(已更新 sys/state.jsonl)');
257
+ }
258
+
259
+ if (problems.length) {
260
+ console.log('');
261
+ for (const p of problems) console.log(`❌ ${p}`);
262
+ console.log('');
263
+ console.log(`未过:${problems.length} 个问题。`);
264
+ return 1;
265
+ }
266
+ console.log('');
267
+ console.log('✅ 五项全过:链接 / 路径 / 分类与配置 / 图与笔记 / 账本引用。');
268
+ return 0;
269
+ }
270
+
271
+ try {
272
+ process.exit(main());
273
+ } catch (err) {
274
+ console.log('❌ scan.mjs 自身出错:' + (err && err.message ? err.message : String(err)));
275
+ process.exit(1);
276
+ }