Files
2026-09-22 13:46:36 +07:00

77 lines
5.0 KiB
JavaScript

#!/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 <HANDOFF_….md> [--root <thư mục gốc dự án>] [--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 <HANDOFF.md>'); 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 | <số> |)')
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)