update skill and docs
This commit is contained in:
190
.claude/skills/ba-lifecycle/scripts/diagram-check.mjs
Normal file
190
.claude/skills/ba-lifecycle/scripts/diagram-check.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
#!/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 <artifact.md> [--md ...] [--spec <x.json> ...]
|
||||
// [--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 <!-- archify: <type> · <spec.json> --> hoặc <!-- archify: mermaid-only -->
|
||||
// A1 spec JSON tồn tại, đúng diagram_type, meta.quality_profile = showcase, không subtitle/visual_preset
|
||||
// A2 `archify validate <type> <spec> --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 <file.md> hoặc --spec <file.json>'); 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*(?:<?-{1,3}[.=-]*>|-{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 = /<!--\s*archify:\s*(architecture|workflow|sequence|dataflow|lifecycle|mermaid-only)(?:\s*[·|,:]\s*([^\s>]+))?\s*-->/
|
||||
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 <!-- archify: <type> · <spec.json> --> hoặc <!-- archify: mermaid-only -->`); 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)
|
||||
Reference in New Issue
Block a user