AI 驱动的国际化审查:硬编码文本检测与 i18n 键值覆盖率分析
AI 驱动的国际化审查硬编码文本检测与 i18n 键值覆盖率分析国际化i18n是前端工程的常见需求但硬编码文本的治理在多语言项目中始终是一个持续性问题。开发过程中容易在组件中直接写入中文字符串等到需要支持多语言时再回头逐个提取成本较高。AI 辅助的硬编码文本检测配合覆盖率分析可以在 CI 阶段提前发现遗漏。一、硬编码文本的检测难点传统的硬编码文本检测依赖正则表达式或 AST 分析但面临几个挑战。首先是误判问题——CSS 类名、API 路径、枚举值这类看起来像文本但不需翻译的字符串会被误报。其次是上下文感知——console.log(调试信息)和Button提交订单/Button中的字符串本质不同前者不需要国际化后者需要。以下是一个结合 AST 和 AI 判断的检测流程graph TD A[源代码文件] -- B[AST 解析] B -- C[提取所有字符串字面量] C -- D{是否为 JSX 文本?} D --|是| E[标记: 需审查文本] D --|否| F{是否在函数调用内?} F --|是| G{函数是否为 i18n API?} G --|是| H[标记: 已国际化] G --|否| I[送入 AI 判断] F --|否| J{是否在变量赋值中?} J --|是| I J --|否| K[跳过] I -- L[AI 判断: 是否需要翻译?] L --|需要| E L --|不需要| K E -- M[生成检测报告] H -- M K -- M核心思路是先用 AST 做第一层过滤——排除已知不需要翻译的模式变量名、路径、枚举值再将边界情况交给 AI 进行语义判断。二、基于 AST 的硬编码字符串提取以下实现从 JSX 组件和普通字符串中提取需要审查的文本跳过已经被t()、i18n.t()等函数包裹的已国际化文本import { parse } from babel/parser; import traverse from babel/traverse; import type { NodePath, Node } from babel/traverse; import type { StringLiteral, JSXText, TemplateLiteral } from babel/types; interface I18nViolation { /** 文件路径 */ file: string; /** 行号 */ line: number; /** 原始文本 */ text: string; /** 文本类型 */ type: jsx_text | string_literal | template_literal; /** 是否需要 AI 二次判断 */ needsAiReview: boolean; } /** * 提取源码中所有可能需要国际化的文本 * param code 源代码字符串 * param filePath 文件路径 */ function extractHardcodedTexts( code: string, filePath: string ): I18nViolation[] { const violations: I18nViolation[] []; // 不需要国际化的文本模式 const skipPatterns [ /^[A-Z_]$/, // 枚举常量: SUBMIT_STATUS /^[a-zA-Z0-9_\-./]$/, // 纯英文/数字/路径: api/users /^#[0-9a-fA-F]{3,8}$/, // 颜色值: #1a56db /^\d(px|em|rem|%|vh|vw)$/, // CSS 单位值 /^https?:\/\//, // URL /^\s*$/, // 空白字符 /^[!#$%^*()_\-[\]{}|;:,.?/~\\]$/, // 纯符号 ]; try { const ast parse(code, { sourceType: module, plugins: [jsx, typescript], errorRecovery: true, }); traverse(ast, { // 检测 JSX 中的文本节点 JSXText(path: NodePathJSXText) { const text path.node.value.trim(); if (!text || shouldSkip(text, skipPatterns)) return; // 检查父节点是否已被 i18n 函数包裹 if (isWrappedByI18n(path)) return; violations.push({ file: filePath, line: path.node.loc?.start.line ?? 0, text, type: jsx_text, needsAiReview: needsAiClassification(text), }); }, // 检测普通字符串字面量 StringLiteral(path: NodePathStringLiteral) { const text path.node.value.trim(); if (!text || shouldSkip(text, skipPatterns)) return; const parent path.parent; // 跳过 import/export 语句中的字符串 if ( parent.type ImportDeclaration || parent.type ExportNamedDeclaration || parent.type ExportAllDeclaration ) { return; } // 跳过对象键名 if ( parent.type ObjectProperty parent.key path.node ) { return; } // 跳过已在 i18n 调用中 if (isInsideI18nCall(path)) return; violations.push({ file: filePath, line: path.node.loc?.start.line ?? 0, text, type: string_literal, needsAiReview: needsAiClassification(text), }); }, // 检测模板字面量无表达式的 TemplateLiteral(path: NodePathTemplateLiteral) { if (path.node.expressions.length 0) return; const text path.node.quasis[0]?.value.raw?.trim(); if (!text || shouldSkip(text, skipPatterns)) return; if (isInsideI18nCall(path)) return; violations.push({ file: filePath, line: path.node.loc?.start.line ?? 0, text, type: template_literal, needsAiReview: needsAiClassification(text), }); }, }); } catch (err) { console.error([i18n Scanner] 解析失败: ${filePath}, err); } return violations; } /** * 判断文本是否匹配跳过模式 */ function shouldSkip( text: string, patterns: RegExp[] ): boolean { return patterns.some((pattern) pattern.test(text)); } /** * 判断文本是否需要 AI 分类 * 包含多语言混合、简短文本(2-3字)等歧义场景需要 AI 协助 */ function needsAiClassification(text: string): boolean { // 短文本且非纯中文: 可能是变量名 if (text.length 3 !/[\u4e00-\u9fff]/.test(text)) { return true; } // 包含中英文混合 if ( /[\u4e00-\u9fff]/.test(text) /[a-zA-Z]/.test(text) ) { return true; } // 包含变量的字符串: 共 {count} 条 if (/\{[a-zA-Z_]\w*\}/.test(text)) { return false; // 明确需要国际化,不需要 AI } return false; } /** * 检查节点是否被 i18n 函数包裹 */ function isWrappedByI18n(path: NodePath): boolean { let current: NodePath | null path.parentPath; while (current) { if (current.isCallExpression()) { const callee (current.node as any).callee; const calleeName getCalleeName(callee); if (/^(t|i18n|translate|\$t|i18next\.t)$/.test(calleeName)) { return true; } // FormattedMessage 组件的检测 if (calleeName FormattedMessage) { return true; } } current current.parentPath; } return false; } /** * 检查字符串是否在 i18n 调用内 */ function isInsideI18nCall(path: NodePath): boolean { let current: NodePath | null path.parentPath; while (current) { if (current.isCallExpression()) { const callee (current.node as any).callee; const calleeName getCalleeName(callee); if (/^(t|i18n\.t|i18next\.t|\$t)$/.test(calleeName)) { return true; } } // 检查是否在 JSX 属性中 if (current.isJSXAttribute()) { const name (current.node as any).name?.name; if (name defaultMessage || name id) { return true; } } current current.parentPath; } return false; } /** * 提取调用表达式的 callee 名称 */ function getCalleeName(callee: any): string { if (!callee) return ; if (callee.type Identifier) { return callee.name; } if (callee.type MemberExpression) { const obj callee.object; const prop callee.property; if (obj.type Identifier prop.type Identifier) { return ${obj.name}.${prop.name}; } } return ; }AST 扫描直接跳过的模式包括枚举常量、纯英文变量名、URL、CSS 值等明确不需要翻译的文本。对于已有t()包裹的已国际化文本通过向上遍历父节点来跳过。边界情况如 2-3 个字符的短文本标记为需要 AI 二次判断。三、AI 辅助的文本分类与翻译键生成对于 AST 无法确定是否需要翻译的文本送入 LLM 进行语义分析interface AiClassificationResult { /** 是否需要国际化 */ needsI18n: boolean; /** 推荐的翻译键名称 */ suggestedKey?: string; /** 分类依据 */ reason: string; } /** * 使用 AI 模型判断文本是否需要国际化 * 在实际项目中,此函数调用 OpenAI/Claude API * param text 待判断的文本 * param fileContext 文本所在文件的路径等信息 */ async function classifyTextWithAI( text: string, fileContext: string ): PromiseAiClassificationResult { // 构建 AI 判断的提示词 const prompt buildClassificationPrompt(text, fileContext); try { // 实际项目中替换为 API 调用 // const response await openai.chat.completions.create({...}) // return JSON.parse(response.content) // 此处使用启发式规则模拟 AI 判断 return heuristicClassify(text); } catch (error) { // AI 不可用时降级为启发式判断 console.warn([i18n AI] API 调用失败,使用启发式规则); return heuristicClassify(text); } } /** * 构建分类提示词 */ function buildClassificationPrompt( text: string, fileContext: string ): string { return 分析以下前端代码中的文本是否需要国际化翻译 文本内容: ${text} 文件上下文: ${fileContext} 判断标准: 1. 如果文本是面向用户的界面文案、提示信息、按钮文字 - 需要国际化 2. 如果文本是变量名、调试信息、技术标识 - 不需要国际化 3. 如果文本是注释、控制台输出 - 不需要国际化 请返回 JSON 格式: {needsI18n: true/false, suggestedKey: 命名空间.键名, reason: 判断依据}; } /** * 启发式分类AI 不可用时的降级方案 */ function heuristicClassify( text: string ): AiClassificationResult { // 全中文文本 - 很可能需要国际化 if (/^[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]$/.test(text)) { return { needsI18n: true, suggestedKey: generateI18nKey(text), reason: 纯中文用户界面文本, }; } // 包含中文的文本 - 需要国际化 if (/[\u4e00-\u9fff]/.test(text)) { return { needsI18n: true, suggestedKey: generateI18nKey(text), reason: 包含中文的用户界面文本, }; } // 纯英文短文本 - 可能是变量名,不需要 if (text.length 10 /^[a-zA-Z0-9_]$/.test(text)) { return { needsI18n: false, reason: 疑似变量名或标识符, }; } // 默认: 纯英文长文本可能需要国际化 return { needsI18n: text.length 20, suggestedKey: text.length 20 ? generateI18nKey(text) : undefined, reason: text.length 20 ? 较长的英文用户文本 : 短文本,不需要国际化, }; } /** * 根据中文文本生成 snake_case 的 i18n 键名 */ function generateI18nKey(text: string): string { // 简化中文到英文的映射实际项目中用更完整方案 const prefix common; const sanitized text .replace(/[\u4e00-\u9fff]/g, text) .replace(/[^a-zA-Z0-9_]/g, _) .replace(/_/g, _) .replace(/^_|_$/g, ) .toLowerCase(); return ${prefix}.${sanitized}.substring(0, 60); }AI 判断的核心价值在于区分用户界面文本和技术标识。例如SUBMIT是一个枚举值不需要翻译而提交表单失败请重试是用户看到的提示文案需要国际化。AI 能够理解中文语义做出比正则表达式更准确的分类。四、i18n 键值覆盖率分析除了检测硬编码文本还需要分析翻译文件的覆盖率——哪些语言包缺失了键值、哪些键在代码中已不再使用/** * i18n 翻译文件覆盖率报告 */ interface CoverageReport { /** 语言包路径 */ localePath: string; /** 总键数 */ totalKeys: number; /** 已翻译的键数 */ translatedKeys: number; /** 未翻译的键 */ missingKeys: string[]; /** 覆盖率百分比 */ coverageRate: number; /** 已废弃的键代码中不再引用 */ unusedKeys: string[]; } /** * 分析 i18n 翻译文件的覆盖率 * param localeDir 翻译文件目录 * param referenceLocale 基准语言如 zh-CN * param targetLocales 目标语言列表 * param usedKeys 代码中实际使用的 i18n 键集合 */ function analyzeI18nCoverage( localeDir: string, referenceLocale: string, targetLocales: string[], usedKeys: Setstring ): CoverageReport[] { const reports: CoverageReport[] []; // 加载基准语言文件获取完整键列表 const referencePath ${localeDir}/${referenceLocale}.json; let referenceKeys: string[] []; try { const refContent JSON.parse( readFileSync(referencePath, utf-8) ); referenceKeys flattenObjectKeys(refContent); } catch { console.error(无法加载基准语言文件: ${referencePath}); return reports; } // 计算未使用的键存在于翻译文件中但代码中不再引用 const unusedKeys referenceKeys.filter( (key) !usedKeys.has(key) ); for (const locale of targetLocales) { const localePath ${localeDir}/${locale}.json; const existingKeys: string[] []; try { const localeContent JSON.parse( readFileSync(localePath, utf-8) ); existingKeys.push( ...flattenObjectKeys(localeContent) ); } catch { console.warn(语言文件不存在或无法解析: ${localePath}); } const missingKeys referenceKeys.filter( (key) !existingKeys.includes(key) ); const totalKeys referenceKeys.length; const translatedKeys totalKeys - missingKeys.length; reports.push({ localePath, totalKeys, translatedKeys, missingKeys, coverageRate: totalKeys 0 ? Math.round( (translatedKeys / totalKeys) * 10000 ) / 100 : 0, unusedKeys, }); } return reports; } /** * 将嵌套的 JSON 对象展平为点分隔的键路径 */ function flattenObjectKeys( obj: Recordstring, any, prefix: string ): string[] { const keys: string[] []; for (const [key, value] of Object.entries(obj)) { const fullKey prefix ? ${prefix}.${key} : key; if ( typeof value object value ! null !Array.isArray(value) ) { keys.push(...flattenObjectKeys(value, fullKey)); } else { keys.push(fullKey); } } return keys; }覆盖率分析的核心价值在于回答两个问题哪些翻译还没完成和哪些翻译键已经作废。前者直接阻塞多语言发布的进度后者说明代码重构后翻译文件没有同步清理。五、总结AI 驱动的国际化审查方案通过AST 提取 AI 分类 覆盖率分析的三层架构实现了对硬编码文本的自动化检测。AST 层负责高效提取所有字符串字面量并跳过明确的非翻译模式AI 层对边界文本进行语义分类区分用户界面文案与技术标识覆盖率分析层监控翻译文件的完整性和代码引用的一致性。该方案的检测准确率取决于 AI 模型的语义理解能力和跳过规则的完善程度。跳过规则需要根据团队编码规范持续维护例如某些项目习惯用isCollapsed这样的布尔值名称作为属性值传递这类文本不应被标记为需要翻译。在实际落地时建议先在部分模块中试运行根据误报情况调整规则再推广到全项目。