style(copy): punctuation rules and site-wide fix with a CI check (QA U11)

- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 20:09:29 +08:00
1 parent 9b017906cf
commit 52402864d6
143 files changed
+985 -606

No files matched your search

+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env -S deno run -A
// 文案标点检查(规范见 docs/design/glossary.md「标点」)。只看源码里的字符串字面量、模板字符串与 JSX 文本,
// 不看注释、类型字面量、对象键、比较/匹配用的字符串(includes / split / === 等)和像正则的字符串。
//
// deno run -A scripts/check-copy-punctuation.mjs # 报告,warn 模式(退出码 0)
// deno run -A scripts/check-copy-punctuation.mjs --strict # 有违规退出码 1
// deno run -A scripts/check-copy-punctuation.mjs --fix [文件…] # 自动改能确定的项(标点、省略号、括号、引号、空格)
// deno run -A scripts/check-copy-punctuation.mjs --by-file # 按文件汇总违规数
//
// 例外:字符串所在行或上一行写 `copy-allow: 原因`;整个文件在前 5 行写 `copy-allow-file: 原因`。
import { readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { join, relative } from "node:path";
import { parse } from "npm:@babel/[email protected]";
const ROOT = new URL("..", import.meta.url).pathname;
const SCOPES = ["apps/web/app", "apps/server/src", "packages/contracts/src"];
const SKIP_DIR = /(^|\/)(node_modules|build|dist|\.react-router|__tests__|fixtures|routes\/dev)(\/|$)/;
const SKIP_FILE = /\.(test|spec)\.tsx?$|\.d\.ts$/;
const CJK = "\\u3400-\\u4dbf\\u4e00-\\u9fff\\uf900-\\ufaff";
const CJK_RE = new RegExp(`[${CJK}]`);
const PH = ""; // 模板表达式 / JSX 表达式占位
const FULL = { ",": ",", ";": ";", ":": ":", "?": "?", "!": "!" };
const args = process.argv.slice(2);
const fix = args.includes("--fix");
const strict = args.includes("--strict");
const byFile = args.includes("--by-file");
const only = args.filter((a) => !a.startsWith("--"));
// ── 规则 ──
/** 返回违规列表 [{rule, index}] */
function findIssues(text) {
if (!CJK_RE.test(text)) return [];
const out = [];
const push = (rule, re) => {
for (const m of text.matchAll(re)) out.push({ rule, index: m.index });
};
push("中文旁用了半角标点", new RegExp(`[${CJK}][,;:?!]|[,;:?!](?=[${CJK}])`, "g"));
push("中文句末用了半角句号", new RegExp(`[${CJK}]\\.(?=\\s|$)`, "g"));
push("连续标点", /[,。;:?!、,;][,。;:?!、,;]|。。|!!|??/g);
push("中英文/数字之间缺空格", new RegExp(`[${CJK}][A-Za-z0-9]|[A-Za-z0-9][${CJK}]`, "g"));
push("省略号应为 …", /\.\.\.|……/g);
push("括号内有中文应用全角括号", new RegExp(`\\([^()]*[${CJK}][^()]*\\)`, "g"));
push("引号应为「」", /[“”]/g);
return out;
}
/** 能确定的自动修正;占位符 PH 视为非中文、非字母数字 */
function fixText(text) {
if (!CJK_RE.test(text)) return text;
let t = text;
t = t.replace(/……|\.\.\./g, "…");
t = t.replace(/“([^“”]*)”/g, "「$1」");
// 括号内含中文 → 全角,去掉括号内外紧贴的空格(由内向外)
const paren = new RegExp(`[ \\t]?\\(([^()]*[${CJK}][^()]*)\\)[ \\t]?`);
for (let i = 0; i < 20 && paren.test(t); i++) {
t = t.replace(paren, (m, inner, off, all) => {
const before = all[off - 1];
const after = all[off + m.length];
const lead = m.startsWith(" ") || m.startsWith("\t") ? (before === undefined || before === PH ? m[0] : "") : "";
const trail = /[ \t]$/.test(m) ? (after === undefined || after === PH ? m.at(-1) : "") : "";
return `${lead}(${inner.trim()})${trail}`;
});
}
// 中文旁的半角 , ; : ? ! → 全角,并去掉其后的空格
t = t.replace(new RegExp(`([${CJK})」])[ \\t]*([,;:?!])[ \\t]*`, "g"), (_m, a, p) => a + FULL[p]);
t = t.replace(new RegExp(`([,;:?!])[ \\t]*(?=[${CJK}(「])`, "g"), (_m, p) => FULL[p]);
// 中文后的半角句号(句末或后跟空白)→ 。
t = t.replace(new RegExp(`([${CJK})」])\\.(?=[ \\t]|$)[ \\t]*`, "g"), "$1。");
// 全角标点两侧不留空格
t = t.replace(/[ \t]+([,。;:?!、)」])/g, "$1").replace(/([,。;:?!、(「])[ \t]+/g, "$1");
// 中英文 / 数字之间加空格
t = t.replace(new RegExp(`([${CJK}])([A-Za-z0-9])`, "g"), "$1 $2");
t = t.replace(new RegExp(`([A-Za-z0-9])([${CJK}])`, "g"), "$1 $2");
return t;
}
// ── 源码遍历 ──
const MATCH_CALLS = new Set([
"includes",
"startsWith",
"endsWith",
"indexOf",
"lastIndexOf",
"split",
"replace",
"replaceAll",
"match",
"matchAll",
"search",
"test",
"has",
"get",
"delete",
"RegExp",
"querySelector",
"querySelectorAll",
"closest",
]);
/** 字面量用作数据(键、匹配、比较、类型)而非给人看的文字 */
function isDataUse(node, parent, key) {
if (!parent) return false;
switch (parent.type) {
case "TSLiteralType":
case "ImportDeclaration":
case "ExportNamedDeclaration":
case "ExportAllDeclaration":
case "SwitchCase":
case "TSEnumMember":
return true;
case "ObjectProperty":
case "ObjectMethod":
case "ClassProperty":
case "TSPropertySignature":
return key === "key";
case "MemberExpression":
case "OptionalMemberExpression":
return key === "property";
case "BinaryExpression":
return ["===", "!==", "==", "!=", "in"].includes(parent.operator);
case "CallExpression":
case "OptionalCallExpression":
case "NewExpression": {
const c = parent.callee;
const name = c.type === "Identifier" ? c.name : c.property?.name;
if (MATCH_CALLS.has(name)) return true;
if (name === "set" && parent.arguments[0] === node) return true;
return false;
}
default:
return false;
}
}
/** 像正则/代码的字符串(\d \( \. 等转义)整条跳过 */
const looksLikeRegex = (raw) => /\\[^nt"'`\\u]/.test(raw);
function collect(ast, src) {
/** @type {{start:number,end:number,text:string,kind:string}[][]} 每组 = 一段连续文字(模板/JSX 子节点按占位拼接) */
const groups = [];
const walk = (node, parent, key) => {
if (!node || typeof node.type !== "string") return;
if (node.type === "StringLiteral") {
const raw = src.slice(node.start + 1, node.end - 1);
if (!isDataUse(node, parent, key) && !looksLikeRegex(raw))
groups.push([{ start: node.start + 1, end: node.end - 1, text: raw }]);
return;
}
if (node.type === "TemplateLiteral") {
if (parent?.type !== "TaggedTemplateExpression" && !isDataUse(node, parent, key)) {
const parts = node.quasis.map((q) => ({ start: q.start, end: q.end, text: src.slice(q.start, q.end) }));
if (!parts.some((p) => looksLikeRegex(p.text))) groups.push(parts);
}
for (const e of node.expressions) walk(e, node, "expressions");
return;
}
if ((node.type === "JSXElement" || node.type === "JSXFragment") && node.children?.length) {
// 相邻 JSXText 与表达式按占位拼成一段(`剩余 {n} 项(共 {m})` 能整体判断)
let run = [];
const flush = () => {
if (run.some(Boolean)) groups.push(run);
run = [];
};
for (const ch of node.children) {
if (ch.type === "JSXText") run.push({ start: ch.start, end: ch.end, text: src.slice(ch.start, ch.end) });
else if (ch.type === "JSXExpressionContainer" && ch.expression.type !== "JSXEmptyExpression") run.push(null);
else flush();
}
flush();
}
for (const k of Object.keys(node)) {
if (k === "loc" || k === "leadingComments" || k === "trailingComments" || k === "innerComments") continue;
const v = node[k];
if (Array.isArray(v)) for (const c of v) walk(c, node, k);
else if (v && typeof v.type === "string") walk(v, node, k);
}
};
walk(ast.program, null, null);
return groups;
}
function lineOf(src, idx) {
let line = 1;
for (let i = 0; i < idx; i++) if (src.charCodeAt(i) === 10) line++;
return line;
}
function allowed(lines, line) {
return /copy-allow/.test(lines[line - 1] ?? "") || /copy-allow/.test(lines[line - 2] ?? "");
}
function processFile(file) {
const src = readFileSync(file, "utf8");
if (/copy-allow-file/.test(src.split("\n").slice(0, 5).join("\n"))) return { issues: [], changed: false };
let ast;
try {
ast = parse(src, {
sourceType: "module",
plugins: file.endsWith(".tsx") ? ["typescript", "jsx"] : ["typescript"],
errorRecovery: true,
});
} catch (e) {
return { issues: [{ line: 0, rule: `解析失败: ${e.message}`, excerpt: "" }], changed: false };
}
const lines = src.split("\n");
const issues = [];
const edits = [];
for (const g of collect(ast, src)) {
const texts = g.filter(Boolean);
if (texts.length === 0) continue;
const composed = joinWithPH(g);
for (const is of findIssues(composed.text)) {
const loc = composed.locate(is.index);
if (!loc) continue;
const line = lineOf(src, loc);
if (allowed(lines, line)) continue;
issues.push({ line, rule: is.rule, excerpt: composed.text.slice(Math.max(0, is.index - 12), is.index + 14) });
}
if (fix) {
const firstLine = lineOf(src, texts[0].start);
if (allowed(lines, firstLine)) continue;
const fixed = fixText(composed.text);
if (fixed !== composed.text) {
const pieces = fixed.split(PH);
if (pieces.length === composed.slots.length)
composed.slots.forEach((p, i) => {
if (p && pieces[i] !== p.text) edits.push({ start: p.start, end: p.end, text: pieces[i] });
});
}
}
}
let changed = false;
if (fix && edits.length) {
let out = src;
for (const e of edits.sort((a, b) => b.start - a.start)) out = out.slice(0, e.start) + e.text + out.slice(e.end);
if (out !== src) {
writeFileSync(file, out);
changed = true;
}
}
return { issues, changed, edits: edits.length };
}
/**
* 把一组片段拼成带占位的文本。模板字面量:片段之间天然隔一个表达式;JSX:null 是表达式。
* slots 与按 PH 切分后的段一一对应(表达式位置为 null 段不会出现:相邻表达式之间插空文本段)。
*/
function joinWithPH(g) {
const slots = [];
const isJsx = g.includes(null);
if (isJsx) {
// JSX:文本段与表达式交替;两个表达式相邻时补一个空文本段
let lastWasText = false;
for (const p of g) {
if (p) {
slots.push(p);
lastWasText = true;
} else {
if (!lastWasText) slots.push(null);
lastWasText = false;
}
}
if (!lastWasText) slots.push(null);
} else slots.push(...g);
const text = slots.map((p) => (p ? p.text : "")).join(PH);
const offsets = [];
let acc = 0;
for (const p of slots) {
offsets.push(acc);
acc += (p ? p.text.length : 0) + 1;
}
return {
text,
slots,
locate(idx) {
for (let i = slots.length - 1; i >= 0; i--)
if (idx >= offsets[i])
return slots[i] ? slots[i].start + Math.min(idx - offsets[i], slots[i].text.length) : null;
return null;
},
};
}
function listFiles() {
if (only.length) return only.map((f) => join(process.cwd(), f));
const out = [];
const rec = (dir) => {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
const rel = relative(ROOT, p);
if (SKIP_DIR.test(rel)) continue;
const st = statSync(p);
if (st.isDirectory()) rec(p);
else if (/\.tsx?$/.test(name) && !SKIP_FILE.test(name)) out.push(p);
}
};
for (const s of SCOPES) rec(join(ROOT, s));
return out.sort();
}
let total = 0;
let changedFiles = 0;
let editCount = 0;
const byRule = new Map();
for (const f of listFiles()) {
const r = processFile(f);
if (r.changed) {
changedFiles++;
editCount += r.edits;
}
const issues = fix ? processFile(f).issues : r.issues;
if (byFile && issues.length) console.log(`${String(issues.length).padStart(4)} ${relative(ROOT, f)}`);
for (const i of issues) {
total++;
byRule.set(i.rule, (byRule.get(i.rule) ?? 0) + 1);
if (!byFile && total <= 200)
console.log(`${relative(ROOT, f)}:${i.line} ${i.rule} …${i.excerpt.replaceAll(PH, "{…}")}…`);
}
}
if (!byFile && total > 200) console.log(`…另有 ${total - 200} 处未列出`);
if (fix) console.log(`已修改 ${changedFiles} 个文件、${editCount} 处字面量`);
console.log(
total
? `${strict ? "✗" : "⚠"} 文案标点:${total} 处(${[...byRule].map(([k, v]) => `${k} ${v}`).join(";")})`
: "✓ 文案标点:0 处",
);
if (strict && total) process.exit(1);