#!/usr/bin/env node // handoff-check.mjs — kiểm gói bàn giao DEV (HANDOFF): mọi đường dẫn ở §2 tồn tại, header khớp // Version/Status, không còn "dự kiến". Chỉ Node ≥ 18, không phụ thuộc gói ngoài. Exit 1 khi có 🔴. // // node handoff-check.mjs --handoff [--root ] [--json] // // Phép kiểm: // H1 mọi ô "Đường dẫn" ở bảng §2.1/§2.2 có ≥1 đường dẫn thật (không rỗng, không "…", không "dự kiến") // H2 đường dẫn tồn tại (hỗ trợ glob * đơn giản trong tên file) // H3 với file .md: Version/Status trong header khớp ô Version/Status của bảng // H4 dòng gate AG2/G3 mà Status không phải 🔵/✅ ⇒ 🟠 (bàn giao trên bản Draft) // H5 file contract (.yaml) và migration (.sql) được tham chiếu tồn tại; migration có file .down.sql import fs from 'node:fs' import path from 'node:path' const args = process.argv.slice(2) const opt = {} for (let i = 0; i < args.length; i++) { const a = args[i]; 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.handoff) { console.error('Thiếu --handoff '); process.exit(2) } const root = path.resolve(opt.root || process.cwd()) const out = []; let red = 0 const ok = (id, msg) => out.push({ id, status: '✅', msg }) const warn = (id, msg) => out.push({ id, status: '🟠', msg }) const bad = (id, msg) => { red++; out.push({ id, status: '🔴', msg }) } const text = fs.readFileSync(opt.handoff, 'utf8') const expand = (p) => { // glob * chỉ ở tên file const abs = path.isAbsolute(p) ? p : path.join(root, p) if (!abs.includes('*')) return fs.existsSync(abs) ? [abs] : [] const dir = path.dirname(abs); const re = new RegExp('^' + path.basename(abs).replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$') if (!fs.existsSync(dir)) return [] return fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) } const header = (file) => { const t = fs.readFileSync(file, 'utf8').slice(0, 4000) const v = (t.match(/\|\s*\*\*Version\*\*\s*\|\s*([^|\n]+)\|/) || [])[1] const s = (t.match(/\|\s*\*\*Status\*\*\s*\|\s*([^|\n]+)\|/) || [])[1] return { version: v && v.trim(), status: s && s.trim() } } // bảng §2.x: | # | Cần có | Đường dẫn | Version | Status | Gate | ✅/☐ | const sec2 = text.split(/^## 2\./m)[1]?.split(/^## 3\./m)[0] || '' const rows = [...sec2.matchAll(/^\|\s*(\d+)\s*\|([^\n]*)$/gm)] if (!rows.length) bad('H1', 'Không đọc được bảng §2 (dòng bắt đầu bằng | |)') let checked = 0 for (const r of rows) { const cells = r[2].split('|').map((c) => c.trim()) const [need, pathsCell, version, status, gate] = cells const label = `#${r[1]} ${need}` if (!pathsCell || /^(…|\.\.\.|-|—)?$/.test(pathsCell) || /dự kiến|sẽ có|TBD|TODO/i.test(pathsCell)) { bad('H1', `${label}: chưa có đường dẫn thật ("${pathsCell || ''}")`); continue } const paths = [...pathsCell.matchAll(/`([^`]+)`/g)].map((m) => m[1]).filter((p) => !/^…/.test(p)) if (!paths.length) { bad('H1', `${label}: ô đường dẫn không có \`đường/dẫn\` nào`); continue } for (const p of paths) { if (/…/.test(p)) { bad('H1', `${label}: đường dẫn còn "…" — ${p}`); continue } const found = expand(p) if (!found.length) { bad('H2', `${label}: không tồn tại — ${p}`); continue } checked++ for (const f of found.slice(0, 5)) { if (/\.md$/i.test(f)) { const h = header(f) if (version && version !== '—' && version !== '' && h.version && !h.version.startsWith(version)) bad('H3', `${label}: Version bảng "${version}" ≠ header "${h.version}" (${path.basename(f)})`) if (status && status !== '—' && h.status && !h.status.includes(status.replace(/[^🟡🟠🔵✅📦]/g, '') || status)) bad('H3', `${label}: Status bảng "${status}" ≠ header "${h.status}" (${path.basename(f)})`) if (/AG2|G3/.test(gate || '') && h.status && !/🔵|✅/.test(h.status)) warn('H4', `${label}: bàn giao trên bản ${h.status} (${path.basename(f)}) — gate ${gate} chưa qua`) } if (/\.sql$/i.test(f) && !/\.down\.sql$/i.test(f)) { const down = f.replace(/\.sql$/i, '.down.sql'); if (!fs.existsSync(down)) bad('H5', `${label}: migration ${path.basename(f)} không có ${path.basename(down)}`) } } } } if (checked) ok('H2', `${checked} đường dẫn tồn tại`) const duKien = (text.match(/dự kiến|sẽ có sau/gi) || []).length if (duKien) bad('H1', `HANDOFF còn ${duKien} chỗ "dự kiến/sẽ có sau"`) 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}`); 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)