// Checks existing commits against the CONTRIBUTING.md spec. The spec allows one // line only, so a subject must both parse (emoji + type + Chinese description) // and be the whole message: any body or footer is reported too. // // This is a reporting tool, not a hook: it never blocks a commit. // // Usage: // node scripts/check-commit-log.mjs # newest 10 commits // node scripts/check-commit-log.mjs 50 # newest 50 commits // node scripts/check-commit-log.mjs all # every commit // DSD_SPEC=path/to/CONTRIBUTING.md node scripts/check-commit-log.mjs import { execFileSync } from 'node:child_process' import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const specPath = process.env.DN_SPEC ?? resolve(repoRoot, 'CONTRIBUTING.md') const doc = readFileSync(specPath, 'utf8') const count = process.argv[2] ?? '10' /** The regex the spec documents for a subject line. */ const SUBJECT = new RegExp( '^(?:\\p{Extended_Pictographic}\\uFE0F?\\s+)?' + '(?[a-z]+)' + '(?:\\((?[^)]*)\\))?' + '(?!)?' + ':\\s(?.+)$', 'u', ) // The spec's table is the single source of truth for the type → emoji mapping, // so a table edit cannot drift from what this script checks. const emojiOfType = new Map() for (const row of doc.split('\n').filter((line) => /^\| `[a-z]+` \| /.test(line))) { const cells = row.split('|').map((cell) => cell.trim()) emojiOfType.set(cells[1].replaceAll('`', ''), cells[2]) } if (emojiOfType.size === 0) throw new Error(`${specPath} has no type table to read`) /** Strip the variation selector, so '♻' and '♻️' compare equal. */ const base = (text) => text.replace(/\uFE0F/g, '') /** Display width, counting anything above U+2000 as double. */ const widthOf = (text) => [...text].reduce((n, c) => n + (c.codePointAt(0) > 0x2000 ? 2 : 1), 0) // %B is the raw message body, so a record can hold several lines: a record ends // at the next hash that starts a line, which is what this splits on. const range = count === 'all' ? [] : [`-${Number(count)}`] const raw = execFileSync('git', ['log', ...range, '--format=%h%x09%B%x00'], { encoding: 'utf8', cwd: repoRoot, }) const commits = raw.split('\0').map((chunk) => chunk.replace(/^\n+/, '')).filter((chunk) => chunk.trim() !== '') let problems = 0 for (const chunk of commits) { const tab = chunk.indexOf('\t') const hash = chunk.slice(0, tab) const message = chunk.slice(tab + 1).replace(/\n+$/, '') const lines = message.split('\n') const subject = lines[0] const extra = lines.slice(1).filter((line) => line.trim() !== '') const notes = [] const match = SUBJECT.exec(subject) if (match === null) { notes.push('首行缺少类型前缀,或整体格式不是 [(scope)][!]: <描述>') } else { const expected = emojiOfType.get(match.groups.type) const lead = [...subject][0] if (expected === undefined) notes.push(`类型不在规范表里:${match.groups.type}`) else if (base(lead) !== base(expected)) notes.push(`类型 ${match.groups.type} 的 emoji 应为 ${expected},实际是 ${lead}`) const width = widthOf(subject) if (width > 72) notes.push(`首行宽度 ${width},超过 72`) if (/[。.]$/.test(subject)) notes.push('首行结尾不应有句号') } if (extra.length > 0) notes.push(`带了 ${extra.length} 行正文或脚注,规范要求只有一行`) if (notes.length > 0) problems += 1 const mark = notes.length === 0 ? 'ok ' : 'FAIL' console.log(` ${mark} ${hash} ${notes.length === 0 ? '' : notes.join(';') + ' — '}${subject}`) } console.log(problems === 0 ? `\n检查了 ${commits.length} 条提交,全部符合规范` : `\n检查了 ${commits.length} 条提交,其中 ${problems} 条不符合规范(规范文件:${specPath})`) process.exit(problems === 0 ? 0 : 1)