update skill and docs
This commit is contained in:
76
.claude/skills/sa-3-enablement/scripts/handoff-check.mjs
Normal file
76
.claude/skills/sa-3-enablement/scripts/handoff-check.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user