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,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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// mastery-loop 结构校验
|
|
3
|
+
//
|
|
4
|
+
// 用途:本脚本是结构校验器 —— 查 kb/ 与 sys/ 是否按契约建齐。
|
|
5
|
+
// 契约正文:../templates.md (第一节「库的布局」)
|
|
6
|
+
//
|
|
7
|
+
// 用法:node validate.mjs <工作区根>
|
|
8
|
+
//
|
|
9
|
+
// 硬要求:**永远不要崩溃** —— 干净目录上应报「全缺」并以退出码 1 结束。
|
|
10
|
+
|
|
11
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
|
+
|
|
14
|
+
const KB_FILES = [
|
|
15
|
+
{ rel: 'kb/knowledge', kind: 'dir', label: '知识点笔记目录' },
|
|
16
|
+
{ rel: 'kb/MOC', kind: 'dir', label: '聚合产物目录' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
const SYS_FILES = [
|
|
20
|
+
{ rel: 'sys/config.json', kind: 'json', label: '设定(路径 / 分类 / 语言)' },
|
|
21
|
+
{ rel: 'sys/graph.json', kind: 'json', label: '关系图' },
|
|
22
|
+
{ rel: 'sys/ledger.jsonl', kind: 'jsonl', label: '账本(真值)' },
|
|
23
|
+
{ rel: 'sys/errors.jsonl', kind: 'jsonl', label: '错误原因表' },
|
|
24
|
+
{ rel: 'sys/error-taxonomy.json', kind: 'json', label: '失分原因分类' },
|
|
25
|
+
{ rel: 'sys/paper-format.json', kind: 'json', label: '卷面参数' },
|
|
26
|
+
{ rel: 'sys/state.jsonl', kind: 'jsonl', label: '状态(哈希)' },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function readIfPossible(path) {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(path, 'utf8');
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function countNotes(dir) {
|
|
38
|
+
let n = 0;
|
|
39
|
+
const walk = (d, depth) => {
|
|
40
|
+
if (depth > 3) return;
|
|
41
|
+
let entries = [];
|
|
42
|
+
try {
|
|
43
|
+
entries = readdirSync(d, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (e.name.startsWith('.')) continue;
|
|
49
|
+
if (e.isDirectory()) walk(join(d, e.name), depth + 1);
|
|
50
|
+
else if (/\.md$/i.test(e.name)) n++;
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
walk(dir, 0);
|
|
54
|
+
return n;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 返回问题列表(空 = 通过) */
|
|
58
|
+
function inspect(rel, kind) {
|
|
59
|
+
const problems = [];
|
|
60
|
+
const path = rel;
|
|
61
|
+
const text = readIfPossible(path);
|
|
62
|
+
|
|
63
|
+
if (kind === 'json') {
|
|
64
|
+
let data;
|
|
65
|
+
try {
|
|
66
|
+
data = JSON.parse(text);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
problems.push(`不是合法 JSON:${err.message}`);
|
|
69
|
+
return problems;
|
|
70
|
+
}
|
|
71
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
|
|
72
|
+
problems.push('顶层不是对象');
|
|
73
|
+
return problems;
|
|
74
|
+
}
|
|
75
|
+
if (!data['用途']) problems.push('缺「用途」字段(每个文件开头要有一句「这个文件是干什么的」)');
|
|
76
|
+
if (rel.endsWith('config.json')) {
|
|
77
|
+
if (!data['分类'] || !Array.isArray(data['分类']?.['层级']) || data['分类']['层级'].length === 0) {
|
|
78
|
+
problems.push('缺「分类.层级」—— 分类是全库级定义,必填');
|
|
79
|
+
}
|
|
80
|
+
if (!data['路径'] || !data['路径']['知识库']) problems.push('缺「路径.知识库」');
|
|
81
|
+
}
|
|
82
|
+
if (rel.endsWith('graph.json')) {
|
|
83
|
+
if (!Array.isArray(data['edges'])) problems.push('缺「edges」数组');
|
|
84
|
+
if (!data['规则']) problems.push('缺「规则」字段(前置判据)');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (kind === 'jsonl') {
|
|
89
|
+
const lines = String(text).split(/\r?\n/).filter((l) => l.trim());
|
|
90
|
+
if (!lines.length) {
|
|
91
|
+
problems.push('空文件 —— 至少要有一行 header');
|
|
92
|
+
return problems;
|
|
93
|
+
}
|
|
94
|
+
let head = null;
|
|
95
|
+
try {
|
|
96
|
+
head = JSON.parse(lines[0]);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
problems.push(`第一行不是合法 JSON:${err.message}`);
|
|
99
|
+
}
|
|
100
|
+
if (head && head.kind !== 'header') problems.push('第一行不是 `{"kind":"header","用途":"…"}` 头部记录');
|
|
101
|
+
if (head && !head['用途']) problems.push('头部记录缺「用途」');
|
|
102
|
+
for (let i = 1; i < lines.length; i++) {
|
|
103
|
+
try {
|
|
104
|
+
JSON.parse(lines[i]);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
problems.push(`第 ${i + 1} 行不是合法 JSON:${err.message}`);
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return problems;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function main() {
|
|
116
|
+
const arg = process.argv[2];
|
|
117
|
+
if (!arg) {
|
|
118
|
+
console.log('用法:node validate.mjs <工作区根>');
|
|
119
|
+
process.exit(2);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const root = resolve(arg);
|
|
123
|
+
console.log('校验工作区根:' + root);
|
|
124
|
+
|
|
125
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
126
|
+
console.log('❌ 这个目录不存在,或不是目录。');
|
|
127
|
+
process.exit(1);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let missing = 0;
|
|
131
|
+
let problematic = 0;
|
|
132
|
+
|
|
133
|
+
const dirs = [...KB_FILES, ...SYS_FILES].filter((f) => f.kind === 'dir');
|
|
134
|
+
const files = [...KB_FILES, ...SYS_FILES].filter((f) => f.kind !== 'dir');
|
|
135
|
+
|
|
136
|
+
for (const f of dirs) {
|
|
137
|
+
const p = join(root, f.rel);
|
|
138
|
+
if (!existsSync(p) || !statSync(p).isDirectory()) {
|
|
139
|
+
console.log(`❌ ${f.rel}/ —— 缺失(${f.label})`);
|
|
140
|
+
missing++;
|
|
141
|
+
} else if (f.rel === 'kb/knowledge') {
|
|
142
|
+
const n = countNotes(p);
|
|
143
|
+
console.log(`✅ ${f.rel}/ —— ${f.label}(${n} 篇知识点)`);
|
|
144
|
+
} else {
|
|
145
|
+
console.log(`✅ ${f.rel}/ —— ${f.label}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
const p = join(root, f.rel);
|
|
151
|
+
if (!existsSync(p)) {
|
|
152
|
+
console.log(`❌ ${f.rel} —— 缺失(${f.label})`);
|
|
153
|
+
missing++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const problems = inspect(p, f.kind);
|
|
157
|
+
if (!problems.length) {
|
|
158
|
+
console.log(`✅ ${f.rel} —— ${f.label}`);
|
|
159
|
+
} else {
|
|
160
|
+
console.log(`⚠️ ${f.rel} —— ${f.label}`);
|
|
161
|
+
for (const pr of problems) console.log(` · ${pr}`);
|
|
162
|
+
problematic++;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log('');
|
|
167
|
+
if (missing === 0 && problematic === 0) {
|
|
168
|
+
console.log('✅ 结构齐:kb/ 与 sys/ 都按契约建好了。');
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
console.log(`未完成:缺 ${missing} 个、有问题的 ${problematic} 个。`);
|
|
172
|
+
console.log('跑一遍 `mastery-loop-setup` 补齐。');
|
|
173
|
+
process.exit(1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
main();
|
|
178
|
+
} catch (err) {
|
|
179
|
+
console.log('❌ 校验脚本自身出错:' + (err && err.message ? err.message : String(err)));
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|