#!/usr/bin/env node // contract-check.mjs — kiểm cấu trúc tối thiểu cho file contract của CTR (OpenAPI 3.1 / AsyncAPI 3.0) // khi không có spectral. Chỉ Node ≥ 18, không phụ thuộc gói ngoài, đọc YAML bằng regex theo quy ước // của templates/contracts/*.template.yaml. Không sửa file. Exit 1 khi có 🔴. // // node contract-check.mjs --dir <…/02-architecture/contracts> [--icd ] [--srs ...] // [--api .md> ...] [--json] // // Phép kiểm: // C1 mọi file .yaml parse tối thiểu: có `openapi: 3.1` hoặc `asyncapi: 3.0`, có info.title/version // C2 operationId duy nhất toàn thư mục; mọi operation có operationId, x-if, x-error-codes, responses // C3 IF-nnn sync trong ICD §2 ⇒ có ≥1 operation x-if; IF async ⇒ có channel/operation trong AsyncAPI // C4 mã lỗi E-… trong SRS §4.1 ⇒ xuất hiện trong ≥1 x-error-codes // C5 endpoint `METHOD /path` trong API_.md ⇒ có trong OpenAPI paths // C6 quy ước ICD §1: không có `format: int64` cho id/amount; date-time cho thời gian; response 4xx dùng Error // C7 mỗi operation có ≥1 example import fs from 'node:fs' import path from 'node:path' const args = process.argv.slice(2) const opt = { srs: [], api: [] } for (let i = 0; i < args.length; i++) { const a = args[i] if (a === '--srs') opt.srs.push(args[++i]) else if (a === '--api') opt.api.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.dir) { console.error('Thiếu --dir '); process.exit(2) } const out = [] let red = 0 const ok = (id, msg, d) => out.push({ id, status: '✅', msg, details: d }) const warn = (id, msg, d) => out.push({ id, status: '🟠', msg, details: d }) const bad = (id, msg, d) => { red++; out.push({ id, status: '🔴', msg, details: d }) } const read = (p) => fs.readFileSync(p, 'utf8') const walk = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? walk(path.join(dir, e.name)) : [path.join(dir, e.name)]) : [] const files = walk(opt.dir).filter((f) => /\.ya?ml$/i.test(f)) if (!files.length) { bad('C1', `Không có file .yaml trong ${opt.dir}`) } const allOps = new Map() // operationId -> file const ifCovered = new Set() const errorCodesInContracts = new Set() const pathsInContracts = new Set() // "GET /v1/x" const channels = new Set() for (const f of files) { const t = read(f); const name = path.relative(opt.dir, f) const isOA = /^openapi:\s*['"]?3\.1/m.test(t); const isAA = /^asyncapi:\s*['"]?3\.0/m.test(t) if (!isOA && !isAA) { if (/partners\//.test(name)) warn('C1', `${name}: mirror đối tác — không kiểm phiên bản`); else bad('C1', `${name}: không phải OpenAPI 3.1 hay AsyncAPI 3.0`); continue } if (!/^\s{2}title:\s*\S/m.test(t) || !/^\s{2}version:\s*\S/m.test(t)) bad('C1', `${name}: thiếu info.title hoặc info.version`) else ok('C1', `${name}: ${isOA ? 'OpenAPI 3.1' : 'AsyncAPI 3.0'}`) for (const m of t.matchAll(/^\s*x-if:\s*(IF-\d{3})/gm)) ifCovered.add(m[1]) for (const m of t.matchAll(/x-error-codes:\s*\[([^\]]*)\]/g)) for (const c of m[1].split(',')) { const cc = c.trim().replace(/['"]/g, ''); if (cc) errorCodesInContracts.add(cc) } if (isOA) { // paths: dòng " /xxx:" rồi các method con const lines = t.split('\n'); let curPath = null; let inPaths = false const servers = [...t.matchAll(/^\s*-\s*url:\s*(\S+)/gm)].map((m) => m[1]) const basePath = servers.length ? (servers[0].match(/https?:\/\/[^/]+(\/.*)?$/) || [])[1] || '' : '' for (let i = 0; i < lines.length; i++) { const l = lines[i] if (/^paths:\s*$/.test(l)) { inPaths = true; continue } if (inPaths && /^[a-zA-Z]/.test(l)) inPaths = false if (!inPaths) continue const pm = l.match(/^\s{2}(\/\S*):\s*$/); if (pm) { curPath = pm[1]; continue } const mm = l.match(/^\s{4}(get|post|put|patch|delete|head|options):\s*$/) if (mm && curPath) { const method = mm[1].toUpperCase(); pathsInContracts.add(`${method} ${basePath}${curPath}`); pathsInContracts.add(`${method} ${curPath}`) // đọc block của operation tới method kế tiếp hoặc path kế tiếp let j = i + 1; const blk = [] while (j < lines.length && !/^\s{4}(get|post|put|patch|delete|head|options):\s*$/.test(lines[j]) && !/^\s{2}\/\S*:\s*$/.test(lines[j]) && !/^[a-zA-Z]/.test(lines[j])) { blk.push(lines[j]); j++ } const b = blk.join('\n'); const label = `${name} ${method} ${curPath}` const oid = (b.match(/^\s*operationId:\s*(\S+)/m) || [])[1] if (!oid) bad('C2', `${label}: thiếu operationId`) else if (allOps.has(oid)) bad('C2', `${label}: operationId "${oid}" trùng với ${allOps.get(oid)}`) else { allOps.set(oid, label) } if (!/^\s*x-if:\s*IF-\d{3}/m.test(b)) bad('C2', `${label}: thiếu x-if: IF-nnn`) if (!/x-error-codes:/.test(b)) bad('C2', `${label}: thiếu x-error-codes`) if (!/^\s*responses:\s*$/m.test(b)) bad('C2', `${label}: thiếu responses`) if (!/example/i.test(b) && !/\$ref:\s*'#\/components\/responses/.test(b)) warn('C7', `${label}: không thấy example`) else if (!/examples?:/i.test(b)) warn('C7', `${label}: chỉ có example qua $ref responses — thêm example thành công`) } } if (/format:\s*int64/.test(t)) bad('C6', `${name}: có format int64 — số lớn phải truyền dạng string (ICD §1)`) if (/(At|_at|Time|timestamp)['"]?:\s*\{[^}]*type:\s*string(?![^}]*format:\s*date-time)/.test(t)) warn('C6', `${name}: trường thời gian không có format date-time`) if (!/schemas:\s*[\s\S]*?\n\s{4}Error:/.test(t)) bad('C6', `${name}: thiếu components.schemas.Error { code, message }`) } if (isAA) { for (const m of t.matchAll(/^\s{2}([A-Za-z][\w-]*):\s*$/gm)) { /* channels/messages key */ } const ch = [...t.matchAll(/^\s*address:\s*(\S+)/gm)].map((m) => m[1]) ch.forEach((c) => channels.add(c)) if (!ch.length) bad('C2', `${name}: AsyncAPI không có channel address`) if (!/eventId/.test(t)) bad('C6', `${name}: thiếu eventId trong headers — consumer không khử trùng được (ICD §4)`) if (!/eventVersion/.test(t)) warn('C6', `${name}: thiếu eventVersion`) for (const m of t.matchAll(/^\s*x-if:\s*(IF-\d{3})/gm)) ifCovered.add(m[1]) } } if (allOps.size) ok('C2', `${allOps.size} operationId duy nhất`) // C3: ICD if (opt.icd && fs.existsSync(opt.icd)) { const icd = read(opt.icd) // bảng §2: | `IF-001` | ... | Sync/Async | const rows = [...icd.matchAll(/^\|\s*`(IF-\d{3})`\s*\|([^\n]*)$/gm)] const ifs = new Map() for (const r of rows) { const cells = r[2].split('|').map((c) => c.trim()); const sa = cells.find((c) => /^(sync|async|Sync|Async)/.test(c)) || ''; if (!ifs.has(r[1])) ifs.set(r[1], /async/i.test(sa) && !/sync\s*\(/i.test(sa) ? 'async' : 'sync') } const missing = [...ifs.keys()].filter((k) => !ifCovered.has(k)) if (!ifs.size) warn('C3', `ICD: không đọc được dòng IF-nnn nào ở §2`) else if (!missing.length) ok('C3', `ICD: ${ifs.size}/${ifs.size} IF có contract (x-if)`) else bad('C3', `ICD: ${ifs.size - missing.length}/${ifs.size} IF có contract — thiếu: ${missing.join(', ')}`) const duKien = (icd.match(/dự kiến/gi) || []).length if (duKien) warn('C3', `ICD còn ${duKien} chỗ "dự kiến" — cột "Contract ở đâu" phải trỏ file thật`) } // C4: mã lỗi SRS const eCodes = new Set() for (const s of opt.srs) if (fs.existsSync(s)) for (const m of read(s).matchAll(/\bE-[A-Z]{2,}-\d{4}\b/g)) eCodes.add(m[0]) if (eCodes.size) { const miss = [...eCodes].filter((c) => !errorCodesInContracts.has(c)) if (!miss.length) ok('C4', `${eCodes.size}/${eCodes.size} mã lỗi SRS có trong x-error-codes`) else bad('C4', `${eCodes.size - miss.length}/${eCodes.size} mã lỗi SRS có trong contract — thiếu: ${miss.slice(0, 15).join(', ')}${miss.length > 15 ? '…' : ''}`) } // C5: endpoint API của BA const eps = new Set() for (const a of opt.api) if (fs.existsSync(a)) for (const m of read(a).matchAll(/`(GET|POST|PUT|PATCH|DELETE)\s+(\/[^`\s]*)`/g)) eps.add(`${m[1]} ${m[2].replace(/\{[^}]+\}/g, (x) => x)}`) if (eps.size) { const norm = (s) => s.replace(/\/api/, '').replace(/\{[^}]+\}/g, '{}') const have = new Set([...pathsInContracts].map(norm)) const miss = [...eps].filter((e) => !have.has(norm(e))) if (!miss.length) ok('C5', `${eps.size}/${eps.size} endpoint API của BA có trong OpenAPI`) else bad('C5', `${eps.size - miss.length}/${eps.size} endpoint API của BA có trong OpenAPI — thiếu: ${miss.join(', ')}`) } 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)