-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcheck.js
More file actions
311 lines (284 loc) · 15.1 KB
/
Copy pathcheck.js
File metadata and controls
311 lines (284 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
#!/usr/bin/env node
/**
* check.js — NextCourse 教学设计闭环校验
*
* lint 管样式, check 管教学逻辑。输入见 course-model.js:
* course.meta.md / course.blueprint.md / slides/*.html
*
* 校验项:
* 1. 一致性 : blueprint 模块清单 ↔ meta 大纲 ↔ slides/ 实际页数
* 2. 闭环 : 每条 outcome 至少被 1 个模块教到 + 有 1 条判定证据
* 3. 深度 : Bloom 分布, 全落在 remember/understand 则警告「课程太浅」
* 4. 活动落地: 蓝图里有教学活动的模块, 幻灯片里必须有对应 Activity 页
* 5. 时长 : 各模块时长之和 vs 声明总时长 (含休息与缓冲)
* 6. 完整度 : M/L 档必填章节缺失提醒
* 7. 产出 : 写出 package/7_alignment.md 对齐矩阵 (M/L 档)
*
* 退出码: 有 error 时 1, 只有 warning 时 0
*
* 用法: node check.js <course-name>
*/
'use strict';
const fs = require('fs');
const path = require('path');
const { loadCourse, mapSlidesToModules, isBlank, label, parseMinutes, ALIGNMENT_FILE } = require('./course-model');
const { WORK_ROOT, courseDir, requireCourse } = require('./paths');
// ─── 参数 ────────────────────────────────────────────────────────────────────
const [,, courseName] = process.argv;
if (!courseName) {
console.error('Usage: node check.js <course-name>');
process.exit(1);
}
requireCourse(courseName, 'check');
const course = loadCourse(WORK_ROOT, courseName);
if (!course) {
console.error(`ERROR: course.meta.md not found at ${path.join(courseDir(courseName), 'course.meta.md')}`);
process.exit(1);
}
const {
meta, scale, isMPlus, outcomes, headings,
blueprintName, hasBlueprint, sections, modules, blueprintOutcomeIds,
slides, declaredPages,
} = course;
const SCALE_LABEL = { S: 'S · 分享课', M: 'M · 内训课', L: 'L · 培训项目' };
const bpHeads = sections.map(s => s.title);
const slideCount = slides.length;
// ─── 校验 ────────────────────────────────────────────────────────────────────
const issues = [];
const add = (level, code, msg) => issues.push({ level, code, msg });
const err = (code, msg) => add('error', code, msg);
const warn = (code, msg) => add('warn', code, msg);
const info = (code, msg) => add('info', code, msg);
// 1) 档位与完整度
if (!['S', 'M', 'L'].includes(scale)) {
err('scale', `scale: "${meta.scale}" 不是 S / M / L`);
}
if (isMPlus && !hasBlueprint) {
err('scale', `${scale} 档缺少设计蓝图 ${blueprintName}——新建课程时加 --scale M 会自动放一份模板进去`);
}
if (isMPlus && hasBlueprint) {
const REQUIRED = ['需求诊断', '定位', '目标体系', '整体设计', '模块架构'];
for (const key of REQUIRED) {
if (!bpHeads.some(h => h.includes(key))) {
err('scale', `蓝图缺少必填章节: 含「${key}」的 ## 标题`);
}
}
const LATER = ['评估方案', '内容开发'];
const missingLater = LATER.filter(k => !bpHeads.some(h => h.includes(k)));
if (missingLater.length) {
info('scale', `蓝图待补章节 (${missingLater.join(' / ')})——运行 nextcourse-delivery 技能: ${courseName}`);
}
if (isBlank(meta.duration)) warn('scale', 'frontmatter 缺 duration,时长核算跳过');
if (isBlank(meta.class_size)) warn('scale', 'frontmatter 缺 class_size(分组与互动设计依赖它)');
}
if (!isMPlus && hasBlueprint) {
info('scale', `存在 ${blueprintName} 但 scale 为 S——如果这是内训课,把 frontmatter 改成 scale: M`);
}
// 2) 学习成果本身
const BLOOM = ['remember', 'understand', 'apply', 'analyze', 'evaluate', 'create'];
if (outcomes.length === 0) {
err('outcome', 'frontmatter 里没有 outcomes');
} else {
if (outcomes.length < 3) warn('outcome', `只有 ${outcomes.length} 条学习成果(建议 3–5 条)`);
if (outcomes.length > 5) warn('outcome', `有 ${outcomes.length} 条学习成果(建议不超过 5 条,宁缺毋滥)`);
for (const o of outcomes) {
if (!o.bloom) warn('outcome', `${o.id} 没写 bloom`);
else if (!BLOOM.includes(o.bloom)) warn('outcome', `${o.id} 的 bloom "${o.bloom}" 不是 Bloom 六层之一`);
if (isBlank(o.do)) err('outcome', `${o.id} 的 do 为空`);
if (isBlank(o.success)) warn('outcome', `${o.id} 没写 success(成功标准)`);
}
if (!outcomes.some(o => BLOOM.indexOf(o.bloom) >= 2)) {
warn('depth', '全部成果停留在 remember / understand 层——课程可能太浅,至少 1 条应达到 apply 及以上');
}
}
// 3) 闭环: 教到 + 测到
// S 档不强制闭环(30 分钟的分享课没有考核),只在末尾提示一句,不逐条刷屏
const openLoop = { module: [], evidence: [] };
const closure = isMPlus
? (code, msg) => err(code, msg)
: (code, msg, id, kind) => openLoop[kind].push(id);
for (const o of outcomes) {
if (isBlank(o.module)) {
closure('closure', `${o.id} 没指定 module(在哪个模块教)`, o.id, 'module');
} else if (modules.length) {
for (const n of o.modules) {
const mod = modules.find(m => m.no === n);
if (!mod) {
err('closure', `${o.id} 指向模块 ${n},但蓝图模块清单里没有这个编号`);
} else if (mod.outcomes.length && !mod.outcomes.some(x => x.toUpperCase() === o.id.toUpperCase())) {
warn('closure', `${o.id} 声称在模块 ${n} 教,但模块 ${n} 的「覆盖成果」里没有它(两边对不上)`);
}
}
}
if (isBlank(o.evidence)) {
closure('closure', `${o.id} 没有 evidence(用什么证据判定达成)`, o.id, 'evidence');
}
}
if (openLoop.module.length || openLoop.evidence.length) {
const parts = [];
if (openLoop.module.length) parts.push(`${openLoop.module.join(' ')} 没写 module`);
if (openLoop.evidence.length) parts.push(`${openLoop.evidence.join(' ')} 没写 evidence`);
info('closure', `S 档不强制闭环(${parts.join(';')})——要做「教到 × 测到」的强校验,把 scale 改成 M`);
}
for (const m of modules) {
for (const id of m.outcomes) {
if (!outcomes.some(o => o.id.toUpperCase() === id.toUpperCase())) {
err('closure', `模块 ${m.no} 覆盖成果写了 ${id},但 outcomes 里没有这条`);
}
}
if (!m.outcomes.length) warn('closure', `模块 ${m.no}「${label(m.name)}」没写覆盖成果(它在教哪条 outcome?)`);
if (isBlank(m.deliverable)) warn('closure', `模块 ${m.no}「${label(m.name)}」没有产出物——学员从这个模块带走什么?`);
}
if (blueprintOutcomeIds.length) {
const metaIds = outcomes.map(o => o.id.toUpperCase());
for (const id of blueprintOutcomeIds) {
if (!metaIds.includes(id.toUpperCase())) {
warn('drift', `蓝图目标体系里的 ${id} 在 course.meta.md 的 outcomes 里不存在`);
}
}
for (const id of metaIds) {
if (!blueprintOutcomeIds.some(x => x.toUpperCase() === id)) {
warn('drift', `outcomes 里的 ${id} 没写进蓝图的目标体系表`);
}
}
}
// 4) 一致性: blueprint ↔ meta ↔ slides
const metaModules = headings.filter(h => h.no !== null);
if (modules.length) {
modules.forEach((m, i) => {
if (m.no !== i + 1) warn('drift', `蓝图模块编号不连续: 第 ${i + 1} 行写的是 ${m.no}`);
});
if (metaModules.length && metaModules.length !== modules.length) {
err('drift', `蓝图有 ${modules.length} 个模块,course.meta.md 大纲里有 ${metaModules.length} 个`);
}
for (const mm of metaModules) {
const bm = modules.find(m => m.no === mm.no);
if (!bm) { err('drift', `meta 大纲的模块 ${mm.no}「${label(mm.name)}」不在蓝图模块清单里`); continue; }
// 两边都还是模板占位时不比名字, 那只是「都没填」, 不是漂移
const named = !isBlank(bm.name) && !isBlank(mm.name) && !/^(.*)$/.test(mm.name);
if (named && !bm.name.includes(mm.name) && !mm.name.includes(bm.name)) {
warn('drift', `模块 ${mm.no} 名称不一致: 蓝图「${bm.name}」 vs meta「${mm.name}」(蓝图为准)`);
}
}
} else if (isMPlus && hasBlueprint) {
err('drift', '蓝图第五节没解析到模块清单表(需要含「模块」「时长」两列的表格,列名别改)');
}
if (slideCount === 0) {
info('pages', 'slides/ 还没有页面——运行 nextcourse-slides 技能生成');
} else if (declaredPages === 0) {
info('pages', 'meta 大纲没标页数(### 标题里写「(6 张)」),页数一致性跳过');
} else if (declaredPages !== slideCount) {
warn('pages', `meta 大纲声明 ${declaredPages} 张,slides/ 实有 ${slideCount} 个`);
}
// 5) 幻灯片与蓝图活动的对账(M/L 档,且页数对得上才做映射)
const slideMap = isMPlus ? mapSlidesToModules(course) : null;
if (slideMap) {
for (const m of modules) {
if (isBlank(m.activity)) continue;
const pages = slideMap[m.no] || [];
if (!pages.some(s => s.has('activity-card'))) {
warn('slides', `模块 ${m.no}「${label(m.name)}」蓝图里有活动「${m.activity}」,但这几页里没有 Activity 页(.activity-card)——学员看不到任务、时间与评分点`);
}
}
if (!slides.some(s => s.has('rubric-table'))) {
info('slides', '全 deck 没有 Assessment 页(.rubric-table)——结营要考核的话,学员应该在课上看到评分标准');
}
} else if (isMPlus && slideCount && declaredPages !== slideCount) {
info('slides', '页数与大纲对不上,Activity 页对账跳过(先把 meta 大纲的「(N 张)」改准)');
}
// 6) 时长核算
if (modules.length) {
const unparsed = modules.filter(m => m.minutes === null);
if (unparsed.length) {
warn('duration', `模块时长无法解析: ${unparsed.map(m => `模块 ${m.no}「${m.duration}」`).join(', ')}(写成 60 min 或 1.5h)`);
}
const sum = modules.reduce((a, m) => a + (m.minutes || 0), 0);
const total = parseMinutes(meta.duration);
if (total && sum) {
const gap = total - sum;
const ratio = Math.abs(gap) / total;
if (gap < 0) {
err('duration', `模块时长合计 ${sum} min 超过声明总时长 ${total} min(超 ${-gap} min)`);
} else if (ratio > 0.4) {
warn('duration', `模块时长合计 ${sum} min,声明总时长 ${total} min,缺口 ${gap} min(占 ${Math.round(ratio * 100)}%)——开场收尾休息真占这么多?`);
}
} else if (!total && !isBlank(meta.duration)) {
info('duration', `duration「${meta.duration}」解析不出分钟数,时长核算跳过`);
}
}
// ─── 对齐矩阵 ────────────────────────────────────────────────────────────────
let alignmentPath = null;
if (isMPlus && hasBlueprint && outcomes.length) {
const pkgDir = path.join(course.dir, 'package');
fs.mkdirSync(pkgDir, { recursive: true });
const cell = v => (isBlank(v) ? '—' : String(v).replace(/\|/g, '\\|'));
const rows = outcomes.map(o => {
const mods = o.modules.map(n => modules.find(m => m.no === n)).filter(Boolean);
return `| ${o.id} | ${cell(o.do)} | ${o.bloom || '—'} | ${
mods.length ? mods.map(m => `${m.no} ${label(m.name)}`).join('<br>') : cell(o.module)} | ${
cell(mods.map(m => m.activity).filter(v => !isBlank(v)).join(';'))} | ${
cell(mods.map(m => m.deliverable).filter(v => !isBlank(v)).join(';'))} | ${cell(o.evidence)} |`;
});
const orphan = modules.filter(m => !outcomes.some(o => o.modules.includes(m.no)));
const md = [
// h1 只写功能名, 课程名由 render-md 放到下一行小字 —— 与 package.js 同一口径
`# 对齐矩阵`,
'',
'> 由 `nextcourse check` 自动生成,勿手改。',
`> 数据源:\`${blueprintName}\` 模块清单 + \`course.meta.md\` outcomes。`,
`> 生成时间:${new Date().toISOString().slice(0, 10)}`,
'',
'| 成果 | 学完能做到 | Bloom | 在哪教 | 教学活动 | 学员产出 | 判定证据 |',
'|---|---|---|---|---|---|---|',
...rows,
'',
orphan.length
? `**没有挂上任何学习成果的模块**:${orphan.map(m => `${m.no}「${label(m.name)}」`).join('、')}——要么补 outcome,要么砍掉。`
: '**每个模块都挂到了学习成果。**',
'',
].join('\n');
// 老课程里可能还躺着没有交付序号的 alignment.md, 就地改名, 别留两份
const legacy = path.join(pkgDir, 'alignment.md');
alignmentPath = path.join(pkgDir, ALIGNMENT_FILE);
if (fs.existsSync(legacy) && !fs.existsSync(alignmentPath)) fs.renameSync(legacy, alignmentPath);
fs.writeFileSync(alignmentPath, md, 'utf8');
}
// ─── 报告 ────────────────────────────────────────────────────────────────────
const errors = issues.filter(i => i.level === 'error');
const warnings = issues.filter(i => i.level === 'warn');
const infos = issues.filter(i => i.level === 'info');
const bloomTally = outcomes.reduce((acc, o) => {
const k = o.bloom || '?';
acc[k] = (acc[k] || 0) + 1;
return acc;
}, {});
console.log(`\nNextCourse Check — ${courseName}`);
console.log('─'.repeat(56));
console.log(` 档位 : ${SCALE_LABEL[scale] || scale}${meta.scale ? '' : ' (未声明,按 S 处理)'}`);
console.log(` 蓝图 : ${hasBlueprint ? blueprintName : '(无)'}`);
console.log(` 学习成果 : ${outcomes.length} 条 ${
Object.entries(bloomTally).map(([k, v]) => `${k}×${v}`).join(' ') || ''}`);
console.log(` 模块 : 蓝图 ${modules.length} 个 / meta 大纲 ${metaModules.length} 个`);
console.log(` 页数 : meta 声明 ${declaredPages || '—'} 张 / slides/ 实有 ${slideCount} 个`);
const ICON = { error: '✗', warn: '⚠', info: '·' };
if (issues.length) {
console.log('');
for (const level of ['error', 'warn', 'info']) {
for (const it of issues.filter(i => i.level === level)) {
const line = ` ${ICON[level]} [${it.code.padEnd(8)}] ${it.msg}`;
if (level === 'error') console.error(line); else console.log(line);
}
}
}
if (alignmentPath) {
console.log(`\n ✓ 对齐矩阵已生成: ${path.relative(WORK_ROOT, alignmentPath)}`);
}
console.log(`\n${'─'.repeat(56)}`);
const tail = `${errors.length} error(s), ${warnings.length} warning(s), ${infos.length} note(s)`;
if (errors.length === 0) {
console.log(` PASS 教学设计闭环无阻断问题 — ${tail}\n`);
process.exit(0);
} else {
console.error(` FAIL ${tail}\n`);
process.exit(1);
}