#!/usr/bin/env node // diagram-check.mjs — phép kiểm máy cho sơ đồ theo chuẩn Archify (diagram-rules.md). // Chỉ dùng Node ≥ 18, không phụ thuộc gói ngoài. Không sửa file. Exit 1 khi có 🔴. // // node diagram-check.mjs --md [--md ...] [--spec ...] // [--archify <đường dẫn archify.mjs>] [--no-render] [--json] // // Phép kiểm trên mỗi khối ```mermaid trong .md: // A0 có marker hoặc // A1 spec JSON tồn tại, đúng diagram_type, meta.quality_profile = showcase, không subtitle/visual_preset // A2 `archify validate --quality showcase` pass (0 lỗi composition) // A3 tập ID node trong Mermaid == tập ID trong spec (hai chiều) // A4 tập cạnh from>to trong Mermaid == spec (🔴 architecture/workflow/dataflow/lifecycle · 🟠 sequence) // A5 phong cách: không style/classDef/linkStyle/:::, ID ASCII, ≤ 12 node chính, không ASCII art // A6 W13: có bảng markdown đi kèm trong 40 dòng sau sơ đồ // A7 đã deliver: file .html cạnh spec tồn tại (bỏ qua với --no-render) import fs from 'node:fs' import path from 'node:path' import { spawnSync } from 'node:child_process' import { fileURLToPath } from 'node:url' const here = path.dirname(fileURLToPath(import.meta.url)) const args = process.argv.slice(2) const opt = { md: [], spec: [] } for (let i = 0; i < args.length; i++) { const a = args[i] if (a === '--md') opt.md.push(args[++i]) else if (a === '--spec') opt.spec.push(args[++i]) else if (a.startsWith('--')) { const k = a.slice(2); const nxt = args[i + 1]; if (nxt && !nxt.startsWith('--')) { opt[k] = nxt; i++ } else opt[k] = true } } if (!opt.md.length && !opt.spec.length) { console.error('Thiếu --md hoặc --spec '); process.exit(2) } const ARCHIFY = opt.archify || path.resolve(here, '../../archify/bin/archify.mjs') if (!fs.existsSync(ARCHIFY)) { console.error(`Không thấy Archify tại ${ARCHIFY} — dùng --archify <đường dẫn>`); process.exit(2) } const TYPES = ['architecture', 'workflow', 'sequence', 'dataflow', 'lifecycle'] const NODE_KEY = { architecture: 'components', workflow: 'nodes', sequence: 'participants', dataflow: 'nodes', lifecycle: 'states' } const REL_KEY = { architecture: 'connections', workflow: 'edges', sequence: 'messages', dataflow: 'flows', lifecycle: 'transitions' } const out = [] let red = 0 const ok = (id, msg, details) => out.push({ id, status: '✅', msg, details }) const warn = (id, msg, details) => out.push({ id, status: '🟠', msg, details }) const bad = (id, msg, details) => { red++; out.push({ id, status: '🔴', msg, details }) } const read = (p) => fs.readFileSync(p, 'utf8') const idRe = /^[A-Za-z][A-Za-z0-9_-]*$/ // ---------- Mermaid parsing (đủ cho các mẫu chuẩn của bộ skill) ---------- const stripLabels = (s) => { let prev do { prev = s; s = s.replace(/"[^"]*"/g, '').replace(/\[\[[^\]]*\]\]/g, '').replace(/\(\([^)]*\)\)/g, '').replace(/\(\[[^\]]*\]\)/g, '').replace(/\[\([^)]*\)\]/g, '').replace(/\[[^\[\]]*\]/g, '').replace(/\([^()]*\)/g, '').replace(/\{[^{}]*\}/g, '') } while (s !== prev) return s } const parseMermaid = (code) => { const lines = code.split('\n').map((l) => l.replace(/%%.*$/, '').trim()).filter(Boolean) const kind = (lines[0] || '').split(/\s+/)[0] const nodes = new Set(); const edges = new Set(); const subgraphs = new Set(); const styleHits = [] const body = lines.slice(1) if (/^(flowchart|graph)$/.test(kind)) { for (const raw of body) { if (/^(style|classDef|linkStyle|click)\b/.test(raw) || raw.includes(':::')) { styleHits.push(raw); continue } const sg = raw.match(/^subgraph\s+([A-Za-z][A-Za-z0-9_-]*)/) if (sg) { subgraphs.add(sg[1]); continue } if (raw === 'end' || /^direction\b/.test(raw)) continue const clean = stripLabels(raw).replace(/\|[^|]*\|/g, ' ') const parts = clean.split(/\s*(?:|-{2,3}|={2,3}>|-\.->|--[xo]|<-->|[.-]{2,}[>xo]?)\s*/).map((p) => p.trim()).filter(Boolean) const chain = parts.map((p) => p.split(/\s*&\s*/).map((t) => t.trim()).filter((t) => idRe.test(t))) chain.forEach((grp) => grp.forEach((n) => nodes.add(n))) for (let i = 0; i + 1 < chain.length; i++) for (const a of chain[i]) for (const b of chain[i + 1]) edges.add(`${a}>${b}`) } } else if (kind === 'sequenceDiagram') { for (const raw of body) { const p = raw.match(/^(?:participant|actor)\s+([A-Za-z][A-Za-z0-9_-]*)/) if (p) { nodes.add(p[1]); continue } const e = raw.match(/^([A-Za-z][A-Za-z0-9_]*)\s*-{1,2}(?:>>|>|x|\))\s*([A-Za-z][A-Za-z0-9_]*)\s*:/) if (e) { nodes.add(e[1]); nodes.add(e[2]); edges.add(`${e[1]}>${e[2]}`) } } } else if (/^stateDiagram/.test(kind)) { for (const raw of body) { if (/^(classDef|style)\b/.test(raw) || raw.includes(':::')) { styleHits.push(raw); continue } const s = raw.match(/^state\s+"[^"]*"\s+as\s+([A-Za-z][A-Za-z0-9_-]*)/) if (s) { nodes.add(s[1]); continue } const e = raw.match(/^(\[\*\]|[A-Za-z][A-Za-z0-9_-]*)\s*-->\s*(\[\*\]|[A-Za-z][A-Za-z0-9_-]*)/) if (e) { for (const n of [e[1], e[2]]) if (n !== '[*]') nodes.add(n); if (e[1] !== '[*]' && e[2] !== '[*]') edges.add(`${e[1]}>${e[2]}`) } } } else if (kind === 'erDiagram' || kind === 'classDiagram' || kind === 'quadrantChart') { for (const raw of body) if (/^(style|classDef)\b/.test(raw)) styleHits.push(raw) } return { kind, nodes, edges, subgraphs, styleHits, lineCount: lines.length } } // ---------- Spec ---------- const specIds = (spec) => { const t = spec.diagram_type const nodes = new Set((spec[NODE_KEY[t]] || []).map((n) => n.id)) const edges = new Set((spec[REL_KEY[t]] || []).map((r) => `${r.from}>${r.to}`)) return { nodes, edges } } const runValidate = (type, specPath) => { const r = spawnSync(process.execPath, [ARCHIFY, 'validate', type, specPath, '--quality', 'showcase', '--json'], { encoding: 'utf8' }) let parsed = null try { parsed = JSON.parse(r.stdout) } catch { /* giữ null */ } const okRun = r.status === 0 && parsed && parsed.ok === true && (!parsed.composition || parsed.composition.status === 'pass') const diag = [] if (parsed && Array.isArray(parsed.diagnostics)) for (const d of parsed.diagnostics.slice(0, 8)) diag.push(`${d.code || d.level || ''} ${d.subject || ''} ${d.message || ''}`.trim()) if (!parsed) diag.push((r.stderr || r.stdout || '').trim().split('\n').slice(0, 6).join(' | ')) return { okRun, diag, status: r.status } } const checkSpec = (label, specPath, expectType) => { if (!fs.existsSync(specPath)) { bad('A1', `${label}: không thấy spec ${specPath}`); return null } let spec try { spec = JSON.parse(read(specPath)) } catch (e) { bad('A1', `${label}: spec không phải JSON hợp lệ`, [String(e.message)]); return null } const t = spec.diagram_type if (!TYPES.includes(t)) { bad('A1', `${label}: diagram_type "${t}" không hợp lệ`); return null } if (expectType && t !== expectType) bad('A1', `${label}: marker nói "${expectType}" nhưng spec là "${t}"`) const m = spec.meta || {} if (m.quality_profile !== 'showcase') bad('A1', `${label}: meta.quality_profile phải là "showcase" (đang: ${m.quality_profile ?? 'thiếu'})`) else ok('A1', `${label}: spec ${path.basename(specPath)} · ${t} · showcase`) if (m.subtitle) warn('A1', `${label}: có meta.subtitle — chuẩn Archify mặc định bỏ subtitle`) if (m.visual_preset && m.visual_preset !== 'classic') warn('A1', `${label}: meta.visual_preset="${m.visual_preset}" — mặc định là classic, chỉ đổi khi người dùng yêu cầu`) if (m.legend && m.legend.mode && m.legend.mode !== 'auto') warn('A1', `${label}: meta.legend.mode="${m.legend.mode}" — mặc định auto`) const v = runValidate(t, specPath) if (v.okRun) ok('A2', `${label}: archify validate pass`) else bad('A2', `${label}: archify validate KHÔNG pass (exit ${v.status})`, v.diag) if (!opt['no-render']) { const html = specPath.replace(/\.json$/i, '.html') if (fs.existsSync(html)) ok('A7', `${label}: đã deliver ${path.basename(html)}`) else warn('A7', `${label}: chưa có ${path.basename(html)} — chạy \`archify deliver ${t} ${path.basename(specPath)} ${path.basename(html)} --quality showcase\``) } return spec } // ---------- Markdown ---------- const MARKER = // for (const md of opt.md) { if (!fs.existsSync(md)) { bad('A0', `Không thấy file ${md}`); continue } const text = read(md); const lines = text.split('\n'); const dir = path.dirname(md) const ascii = lines.map((l, i) => (/^\s*[┌└├│─▼┐┘┬┴┼]/.test(l) ? i + 1 : 0)).filter(Boolean) if (ascii.length) bad('A5', `${path.basename(md)}: ASCII art ở dòng ${ascii.slice(0, 5).join(', ')} — phải chuyển sang Mermaid + spec Archify`) let n = 0 for (let i = 0; i < lines.length; i++) { if (!/^\s*```mermaid\s*$/.test(lines[i])) continue n++ const start = i; let j = i + 1 while (j < lines.length && !/^\s*```\s*$/.test(lines[j])) j++ const code = lines.slice(i + 1, j).join('\n'); const label = `${path.basename(md)} sơ đồ #${n} (dòng ${start + 1})` // marker: trong 4 dòng không rỗng phía trước let marker = null for (let k = start - 1, seen = 0; k >= 0 && seen < 4; k--) { if (!lines[k].trim()) continue; seen++; const mm = lines[k].match(MARKER); if (mm) { marker = mm; break } } const pm = parseMermaid(code) if (pm.styleHits.length) bad('A5', `${label}: có style/classDef/linkStyle/::: — không tô màu, phân biệt bằng hình dạng và nhãn`, pm.styleHits.slice(0, 3)) const nonAscii = [...pm.nodes].filter((x) => !/^[A-Za-z][A-Za-z0-9_-]*$/.test(x)) if (nonAscii.length) bad('A5', `${label}: ID node không ASCII`, nonAscii) if (pm.nodes.size > 12) warn('A5', `${label}: ${pm.nodes.size} node chính > 12 — chuẩn Archify: một đường chính, ≤ 12 node; tách sơ đồ`) // W13: bảng đi kèm const after = lines.slice(j + 1, j + 41).join('\n') if (/\n\s*\|[\s:-]*-{3,}[\s:|-]*\|/.test('\n' + after)) ok('A6', `${label}: có bảng đi kèm (W13)`) else bad('A6', `${label}: không thấy bảng markdown trong 40 dòng sau sơ đồ (W13)`) if (!marker) { bad('A0', `${label}: thiếu marker hoặc `); i = j; continue } const type = marker[1] if (type === 'mermaid-only') { const allowed = ['erDiagram', 'classDiagram', 'quadrantChart', 'flowchart', 'graph'] if (allowed.includes(pm.kind)) ok('A0', `${label}: mermaid-only (${pm.kind})`) else warn('A0', `${label}: mermaid-only nhưng loại ${pm.kind} có kiểu Archify tương ứng — nên có spec`) i = j; continue } if (!marker[2]) { bad('A0', `${label}: marker "${type}" thiếu đường dẫn spec JSON`); i = j; continue } ok('A0', `${label}: marker ${type} · ${marker[2]}`) const specPath = path.resolve(dir, marker[2]) const spec = checkSpec(label, specPath, type) if (!spec) { i = j; continue } const si = specIds(spec) const onlyMd = [...pm.nodes].filter((x) => !si.nodes.has(x)); const onlySpec = [...si.nodes].filter((x) => !pm.nodes.has(x)) if (!onlyMd.length && !onlySpec.length) ok('A3', `${label}: ${pm.nodes.size} node khớp hai chiều`) else bad('A3', `${label}: ID node lệch giữa Mermaid và spec`, [`chỉ Mermaid: ${onlyMd.join(', ') || '—'}`, `chỉ spec: ${onlySpec.join(', ') || '—'}`]) const eMd = [...pm.edges].filter((x) => !si.edges.has(x)); const eSpec = [...si.edges].filter((x) => !pm.edges.has(x)) if (!eMd.length && !eSpec.length) ok('A4', `${label}: ${pm.edges.size} cạnh khớp hai chiều`) else (type === 'sequence' ? warn : bad)('A4', `${label}: cạnh lệch giữa Mermaid và spec`, [`chỉ Mermaid: ${eMd.join(', ') || '—'}`, `chỉ spec: ${eSpec.join(', ') || '—'}`]) i = j } if (!n) warn('A0', `${path.basename(md)}: không có khối mermaid nào`) } for (const s of opt.spec) checkSpec(path.basename(s), path.resolve(s), null) // ---------- In ---------- if (opt.json) console.log(JSON.stringify({ red, results: out }, null, 2)) else { for (const r of out) { console.log(`${r.status} ${r.id} ${r.msg}`); if (r.details) for (const d of r.details) console.log(` · ${d}`) } console.log(`\n${red ? `🔴 ${red} lỗi` : '✅ Không có lỗi 🔴'} — ${out.filter((r) => r.status === '🟠').length} cảnh báo 🟠`) } process.exit(red ? 1 : 0)