#!/usr/bin/env node // redline-scan.mjs — 铁律 13 红线词扫描(CI 红线)。 // 扫描 UI 源面(app/ components/ lib/)中的红线词;命中即非零退出。 // 说明:「VPN」仅可出现在技术文档/代码注释,绝不进 UI 文案;域名占位 pangolin.vpn 为小写 // `.vpn`,与红线词大写 `VPN` 不冲突,故按大小写敏感匹配大写 VPN 以避开域名误报。 import { readdir, readFile } from 'node:fs/promises'; import { join, extname } from 'node:path'; const ROOTS = ['app', 'components', 'lib']; const EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.css']); // 红线词(doc CLAUDE §1 铁律 13)。中文短语 + 大写英文术语。 const REDLINES = [ { re: /VPN/g, label: 'VPN' }, { re: /翻墙/g, label: '翻墙' }, { re: /科学上网/g, label: '科学上网' }, { re: /突破封锁/g, label: '突破封锁' }, { re: /自由穿越/g, label: '自由穿越' }, { re: /梯子/g, label: '梯子' }, { re: /机场/g, label: '机场' }, { re: /Go anywhere/gi, label: 'Go anywhere' }, ]; async function walk(dir) { let out = []; let entries; try { entries = await readdir(dir, { withFileTypes: true }); } catch { return out; } for (const e of entries) { if (e.name === 'node_modules' || e.name === 'out' || e.name === '.next') continue; const p = join(dir, e.name); if (e.isDirectory()) out = out.concat(await walk(p)); else if (EXT.has(extname(e.name))) out.push(p); } return out; } async function main() { let files = []; for (const r of ROOTS) files = files.concat(await walk(r)); const hits = []; for (const f of files) { const text = await readFile(f, 'utf8'); const lines = text.split('\n'); lines.forEach((line, i) => { for (const rl of REDLINES) { rl.re.lastIndex = 0; if (rl.re.test(line)) hits.push({ f, n: i + 1, word: rl.label, line: line.trim().slice(0, 100) }); } }); } if (hits.length) { console.error(`✗ 红线词扫描命中 ${hits.length} 处:`); for (const h of hits) console.error(` ${h.f}:${h.n} [${h.word}] ${h.line}`); process.exit(1); } console.log(`✓ 红线词扫描通过(扫描 ${files.length} 个文件,零命中)`); } main();