Files
sys-analysis-design/.claude/skills/sa-2-architecture/scripts/contract-check.mjs
2026-09-22 13:46:36 +07:00

137 lines
9.0 KiB
JavaScript

#!/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 <ICD.md>] [--srs <SRS.md> ...]
// [--api <API_<US>.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_<US>.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 <contracts/>'); 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)