/
s.homyakov
/
dev2harness
Обзор
Документация
Войти
/
s.homyakov
/
dev2harness
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
main
scripts/validate-course.mjs
304 строки
11 KB
Sergey Homyakov
Remove Evidence block from the course
06 авг 2026, 20:41
06 авг 2026, 20:41
1d6a527
Код
Авторство
О чём код?
#!/usr/bin/env node /** * validate-course.mjs — quality gate for course materials in lessons/. * * Errors (exit 1): * - catalog.json structure: every lesson has a folder with the 4 required files * - no orphan lesson folders (present on disk, absent from catalog) * - no real secret-looking strings in lessons/ * - fenced ```json blocks in lessons parse as JSON * - module README artifact promises match exercise.md artifacts * - core course documents exist (GLOSSARY.md, RUBRIC.md) and README has routes * * Warnings (exit 0 unless --strict): * - lesson.md has an anti-pattern section and a handoff section * - exercise.md has checkpoints * - checks.md has at least one machine-runnable command */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(fileURLToPath(new URL("..", import.meta.url))); const lessonsDir = join(root, "lessons"); const REQUIRED_FILES = ["README.md", "lesson.md", "exercise.md", "checks.md"]; const ANTI_PATTERN = /ошибк|анти[- ]?паттерн|не стоит|не делай|не доверя/i; const HANDOFF = /handoff|хэндов[еэ]р|передач|итог|следующ|мини-итог/i; const CHECKPOINTS = /check-?point|контрольн|этап/i; const MACHINE_TOOL = /node|npm|npx|git|rg\b|grep|cat\b|curl|python|jq|playwright/i; const SECRET_PATTERNS = [ { name: "OpenAI-style key", re: /sk-[A-Za-z0-9]{20,}/ }, { name: "GitHub PAT", re: /ghp_[A-Za-z0-9]{20,}/ }, { name: "AWS access key", re: /AKIA[0-9A-Z]{16}/ }, { name: "Slack token", re: /xox[baprs]-[A-Za-z0-9-]{10,}/ }, { name: "private key block", re: /-----BEGIN (RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/ } ]; const SECRET_ASSIGNMENT = /(password|passwd|api[_-]?key|secret|token)\s*[:=]\s*["']([^"']{8,})["']/i; const PLACEHOLDER = /^(your|example|placeholder|changeme|xxx|<[^>]+>)/i; const errors = []; const warnings = []; function read(path) { return existsSync(path) ? readFileSync(path, "utf8") : ""; } function hasSection(text, pattern) { return text .split("\n") .some((line) => /^#{1,6}\s+/.test(line) && pattern.test(line)); } function hasMachineCheck(text) { return text.split("\n").some((line) => { if (!/^[-*]\s+`/.test(line)) return false; const code = (line.match(/`([^`]+)`/g) || []).join(" "); return MACHINE_TOOL.test(code); }); } function loadCatalog() { const catalogPath = join(lessonsDir, "catalog.json"); if (!existsSync(catalogPath)) { errors.push(`Отсутствует ${relative(root, catalogPath)}`); return null; } try { return JSON.parse(read(catalogPath)); } catch (error) { errors.push(`catalog.json не является валидным JSON: ${error.message}`); return null; } } function checkLessonStructure(catalog) { for (const module of catalog.modules) { for (const lesson of module.lessons) { const lessonDir = join(lessonsDir, module.id, lesson.id); if (!existsSync(lessonDir)) { errors.push(`Нет папки урока ${module.id}/${lesson.id}`); continue; } for (const file of REQUIRED_FILES) { if (!existsSync(join(lessonDir, file))) { errors.push(`Урок ${module.id}/${lesson.id}: нет файла ${file}`); } } } } } function checkOrphans(catalog) { const known = new Set( catalog.modules.flatMap((module) => module.lessons.map((lesson) => `${module.id}/${lesson.id}`) ) ); for (const moduleName of readdirSync(lessonsDir)) { if (!/^\d{2}-/.test(moduleName)) continue; const moduleDir = join(lessonsDir, moduleName); for (const entry of readdirSync(moduleDir)) { if (!/^\d{2}-/.test(entry)) continue; const key = `${moduleName}/${entry}`; if (!known.has(key)) { errors.push(`Папка урока ${key} не описана в catalog.json`); } } } } function checkLessonContent(catalog) { for (const module of catalog.modules) { for (const lesson of module.lessons) { const dir = join(lessonsDir, module.id, lesson.id); const label = `${module.id}/${lesson.id}`; const lessonText = read(join(dir, "lesson.md")); const exerciseText = read(join(dir, "exercise.md")); const checksText = read(join(dir, "checks.md")); if (!hasSection(lessonText, ANTI_PATTERN)) { warnings.push(`${label}/lesson.md: нет секции анти-паттернов («ошибки», «не стоит»)`); } if (!hasSection(lessonText, HANDOFF)) { warnings.push(`${label}/lesson.md: нет handoff-секции («передача», «итог»)`); } if (!hasSection(exerciseText, CHECKPOINTS)) { warnings.push(`${label}/exercise.md: нет checkpoints («контрольные точки», «этапы»)`); } if (!hasMachineCheck(checksText)) { warnings.push(`${label}/checks.md: нет исполняемой machine-check команды`); } // Политика ссылок (STYLE.md): md-ссылки в файлах уроков мертвы в UI. const lessonCount = catalog.modules.reduce((total, mod) => total + mod.lessons.length, 0); const contentFiles = { "lesson.md": lessonText, "exercise.md": exerciseText, "checks.md": checksText }; for (const [fileName, text] of Object.entries(contentFiles)) { const mdLink = text.match(/\]\([^)]*\.md\)/); if (mdLink) { errors.push(`${label}/${fileName}: md-ссылка на файл (мертва в UI): ${mdLink[0]}`); } const badRefs = [...new Set( [...text.matchAll(/\bурок\s+(\d{1,2})\b/gi)] .map((match) => Number.parseInt(match[1], 10)) .filter((number) => number < 1 || number > lessonCount) )]; if (badRefs.length) { errors.push(`${label}/${fileName}: ссылка на несуществующий номер урока: ${badRefs.join(", ")}`); } } } } } function checkSecrets() { const files = []; const walk = (dir) => { for (const entry of readdirSync(dir)) { const path = join(dir, entry); if (entry.endsWith(".md")) files.push(path); else if (!entry.includes(".")) walk(path); } }; walk(lessonsDir); for (const file of files) { const lines = read(file).split("\n"); lines.forEach((line, index) => { for (const { name, re } of SECRET_PATTERNS) { if (re.test(line)) { errors.push(`${relative(root, file)}:${index + 1}: похоже на реальный секрет (${name})`); } } const match = line.match(SECRET_ASSIGNMENT); if (match && !PLACEHOLDER.test(match[2])) { errors.push( `${relative(root, file)}:${index + 1}: присваивание секрета со значением («${match[1]}»), похоже на реальный ключ` ); } }); } } function checkJsonFences() { const walk = (dir) => { for (const entry of readdirSync(dir)) { const path = join(dir, entry); if (entry.endsWith(".md")) { const text = read(path); const fences = text.match(/```json\n([\s\S]*?)```/g) || []; fences.forEach((fence, index) => { const body = fence.replace(/^```json\n/, "").replace(/\n```$/, "").trim(); try { JSON.parse(body); } catch (error) { const looksComplete = /^[{[]/.test(body); const message = `${relative(root, path)}: JSON-блок #${index + 1} не парсится: ${error.message}`; if (looksComplete) errors.push(message); else warnings.push(`${message} (фрагмент конфига — допустимо)`); } }); } else if (existsSync(path) && !entry.includes(".")) { walk(path); } } }; walk(lessonsDir); } function checkArtifactPromises(catalog) { for (const module of catalog.modules) { const readme = read(join(lessonsDir, module.id, "README.md")); const lines = readme.split("\n"); const headerIndex = lines.findIndex((line) => /^\|\s*ID\s*\|/i.test(line)); if (headerIndex === -1) continue; const headerCells = lines[headerIndex].split("|").map((cell) => cell.trim()); const artifactColumn = headerCells.findIndex((cell) => /артефакт/i.test(cell)); if (artifactColumn === -1) continue; for (const line of lines) { const row = line.match(/^\|\s*(\d{2})\s*\|/); if (!row) continue; const lesson = module.lessons.find((l) => l.id.startsWith(`${row[1]}-`)); if (!lesson) continue; const cells = line.split("|").map((cell) => cell.trim()); const artifactCell = cells[artifactColumn] || ""; const artifacts = [...artifactCell.matchAll(/`([^`]+)`/g)].map((m) => m[1]); for (const artifact of artifacts) { if (!/\.\w{1,5}$/.test(artifact)) continue; if (artifact.includes("<") || artifact.includes(">")) continue; // шаблон с плейсхолдером const exercise = read(join(lessonsDir, module.id, lesson.id, "exercise.md")); if (!exercise.includes(artifact)) { errors.push( `${module.id}: README обещает артефакт \`${artifact}\` (урок ${row[1]}), но в exercise.md он не упомянут` ); } } } } } function checkCoreDocuments() { if (!existsSync(join(lessonsDir, "GLOSSARY.md"))) { errors.push("Нет lessons/GLOSSARY.md (единый словарь терминов)"); } if (!existsSync(join(lessonsDir, "RUBRIC.md"))) { errors.push("Нет lessons/RUBRIC.md (рубрика зрелости harness)"); } if (!existsSync(join(lessonsDir, "00-templates", "spec.md"))) { errors.push("Нет lessons/00-templates/spec.md (шаблон спецификации SDD)"); } if (!existsSync(join(lessonsDir, "CHEATSHEET.md"))) { errors.push("Нет lessons/CHEATSHEET.md (шпаргалка по командам и конфигу)"); } if (!existsSync(join(lessonsDir, "assets", "miniproject"))) { errors.push("Нет lessons/assets/miniproject/ (сквозной репозиторий-пример)"); } const readme = read(join(lessonsDir, "README.md")); if (!/^## .*Маршрут/m.test(readme)) { errors.push("lessons/README.md: нет секции «Маршруты прохождения»"); } } function main() { const strict = process.argv.includes("--strict"); const catalog = loadCatalog(); if (catalog) { checkLessonStructure(catalog); checkOrphans(catalog); checkLessonContent(catalog); checkArtifactPromises(catalog); } checkSecrets(); checkJsonFences(); checkCoreDocuments(); console.log("Course validator"); console.log("================"); console.log(`errors: ${errors.length}`); console.log(`warnings: ${warnings.length}`); console.log(""); if (errors.length) { console.log("Errors:"); for (const error of errors) console.log(` [error] ${error}`); console.log(""); } if (warnings.length) { console.log("Warnings:"); for (const warning of warnings) console.log(` [warning] ${warning}`); console.log(""); } const failed = errors.length > 0 || (strict && warnings.length > 0); if (failed) { console.log("FAIL: курс не проходит проверку." + (strict && !errors.length ? " (strict mode)" : "")); process.exit(1); } console.log("OK: структура и safety-проверки курса пройдены."); } main();