// check-copy-voice.mjs // // Fails when newly-added lines in our own user-facing copy contain characters the // voice guide bans outright: em dashes, the horizontal bar, and curly quotes. These are // the mechanically-detectable tells from the humanizer rules. The full voice pass // (rule-of-three, promotional tone, negative parallelism, etc.) still needs the // `/humanizer` skill; this only stops the hard bans reaching main. // // Ratchet design (mirrors Sifa's check-copy-voice): only ADDED lines in the diff are // scanned, so the existing backlog of stray characters does not block every commit. New // copy must be clean, and editing an old offending line forces it clean too. // // Scope: src/i18n/*.ts (the strings), plus .astro under src/components, src/pages, // src/layouts (inline JSX-ish copy), and src/data/*.ts (tool/community names + blurbs). // // Plain ESM so it runs with node (no tsx). The pure core (findVoiceViolations, // parseAddedLines) is exported for the vitest unit test. // // Usage: // node scripts/check-copy-voice.mjs --staged # pre-commit (cached diff) // node scripts/check-copy-voice.mjs --base HEAD~1 # push CI (diff vs previous tip) // node scripts/check-copy-voice.mjs --base origin/main # PR-style (diff vs base ref) import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { resolve } from 'node:path'; /** * Characters banned in user-facing copy. En dash (U+2013) and double-hyphen are * deliberately absent: en dashes are legitimate in numeric ranges, and `--` appears in * CSS custom properties and CLI flags. * @type {ReadonlyArray<{ char: string, name: string }>} */ export const BANNED_CHARS = [ { char: '—', name: 'em dash' }, { char: '―', name: 'horizontal bar' }, { char: '“', name: 'curly double quote (open)' }, { char: '”', name: 'curly double quote (close)' }, { char: '‘', name: 'curly single quote (open)' }, { char: '’', name: 'curly apostrophe' }, ]; /** * Pure core: scan already-collected added lines for banned characters. * @param {Array<{ file: string, lineNo: number, content: string }>} lines * @returns {Array<{ file: string, lineNo: number, char: string, name: string, content: string }>} */ export function findVoiceViolations(lines) { const out = []; for (const { file, lineNo, content } of lines) { for (const ch of content) { const banned = BANNED_CHARS.find((b) => b.char === ch); if (banned) out.push({ file, lineNo, char: ch, name: banned.name, content }); } } return out; } // `:(glob)` magic so `**/` matches zero-or-more directories (including files sitting // directly in the scoped folder). Without it git treats `*` as not crossing slashes. const SCAN_PATHSPECS = [ ':(glob)src/i18n/*.ts', ':(glob)src/data/*.ts', ':(glob)src/components/**/*.astro', ':(glob)src/pages/**/*.astro', ':(glob)src/layouts/**/*.astro', ]; /** * Parse a `git diff --unified=0` patch into the new-side added lines. * @param {string} diff * @returns {Array<{ file: string, lineNo: number, content: string }>} */ export function parseAddedLines(diff) { const out = []; let file = ''; let newLineNo = 0; for (const raw of diff.split('\n')) { if (raw.startsWith('+++ ')) { // `+++ b/path` (or `+++ /dev/null` for deletions) const path = raw.slice(4).replace(/^b\//, ''); file = path === '/dev/null' ? '' : path; continue; } if (raw.startsWith('@@')) { // @@ -a,b +c,d @@ -> next added line starts at c const match = /\+(\d+)/.exec(raw.split('@@')[1] ?? ''); newLineNo = match ? Number(match[1]) : 0; continue; } if (raw.startsWith('+')) { // Real `+++ b/path` headers handled above; a bare `+` is an added line whose // content (raw.slice(1)) may itself start with `+`. if (file) out.push({ file, lineNo: newLineNo, content: raw.slice(1) }); newLineNo++; continue; } if (raw.startsWith('-') || raw.startsWith('\\')) continue; if (newLineNo > 0) newLineNo++; } return out; } function gitDiff(args) { return execFileSync( 'git', ['diff', '--unified=0', '--no-color', '--diff-filter=AM', ...args, '--', ...SCAN_PATHSPECS], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }, ); } function collectAddedLines() { const argv = process.argv.slice(2); if (argv.includes('--staged') || argv.includes('--cached')) { return parseAddedLines(gitDiff(['--cached'])); } const baseFlag = argv.indexOf('--base'); if (baseFlag !== -1) { const base = argv[baseFlag + 1]; if (!base) { console.error('check-copy-voice: --base requires a ref argument'); process.exit(2); } // two-dot: everything on the checked-out tip not in base. On a merge-to-main push // with --base HEAD~1 that is the whole merged feature; with --base origin/main on a // PR it is the PR's changes. return parseAddedLines(gitDiff([`${base}..HEAD`])); } console.error('check-copy-voice: pass --staged or --base '); process.exit(2); } function main() { const violations = findVoiceViolations(collectAddedLines()); if (violations.length === 0) { console.log('check-copy-voice: OK, no banned characters in added copy.'); return; } console.error(`check-copy-voice: ${violations.length} banned character(s) in added copy:\n`); for (const v of violations) { console.error(` ${v.file}:${v.lineNo} ${v.name} (${v.char})`); console.error(` ${v.content.trim()}`); } console.error( '\nThese characters are banned in user-facing copy. Use straight quotes and replace em\n' + 'dashes with a colon, comma, or period. Run /humanizer on new copy before committing.', ); process.exit(1); } const invokedPath = process.argv[1] ? resolve(process.argv[1]) : ''; if (invokedPath === fileURLToPath(import.meta.url)) main();