cherry-studio/scripts/check-hardcoded-strings.ts
SuYao 5c013cdb63
Some checks failed
Auto I18N Weekly / Auto I18N (push) Has been cancelled
feat(i18n): add hardcoded string detection script and CI check (#12547)
* feat(i18n): clean hardcoded ui string add ci

* fix(i18n): update snap

* fix: test

* fix(i18n): update plurals

* chore: revert other branch change

* refactor: use ast detect hardcoded string

* chore: typo error

* feat(i18n): add webview app

* chore(i18n): update i18n

* fix: pr comment

* chore: distinguish label and labelKey

* refactor: align v2 desgin
2026-01-24 18:19:38 +08:00

465 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* AST-based hardcoded string detection for i18n
*/
import * as fs from 'fs'
import * as path from 'path'
import type { SourceFile } from 'ts-morph'
import { Node, Project } from 'ts-morph'
const RENDERER_DIR = path.join(__dirname, '../src/renderer/src')
const MAIN_DIR = path.join(__dirname, '../src/main')
const EXTENSIONS = ['.tsx', '.ts']
const IGNORED_DIRS = ['__tests__', 'node_modules', 'i18n', 'locales', 'types', 'assets']
const IGNORED_FILES = ['*.test.ts', '*.test.tsx', '*.d.ts', '*prompts*.ts']
// 'content' is handled specially - only checked for specific components
const UI_ATTRIBUTES = [
'placeholder',
'title',
'label',
'message',
'description',
'tooltip',
'buttonLabel',
'name',
'detail',
'body'
]
const CONTEXT_SENSITIVE_ATTRIBUTES: Record<string, string[]> = {
content: ['Tooltip', 'Popover', 'Modal', 'Popconfirm', 'Alert', 'Notification', 'Message']
}
const UI_PROPERTIES = ['message', 'text', 'title', 'label', 'placeholder', 'description', 'detail']
interface Finding {
file: string
line: number
content: string
type: 'chinese' | 'english'
source: 'renderer' | 'main'
nodeType: string
}
const CJK_RANGES = [
'\u3000-\u303f', // CJK Symbols and Punctuation
'\u3040-\u309f', // Hiragana
'\u30a0-\u30ff', // Katakana
'\u3100-\u312f', // Bopomofo
'\u3400-\u4dbf', // CJK Unified Ideographs Extension A
'\u4e00-\u9fff', // CJK Unified Ideographs
'\uac00-\ud7af', // Hangul Syllables
'\uf900-\ufaff' // CJK Compatibility Ideographs
].join('')
function hasCJK(text: string): boolean {
return new RegExp(`[${CJK_RANGES}]`).test(text)
}
function hasEnglishUIText(text: string): boolean {
const words = text.trim().split(/\s+/)
if (words.length < 2 || words.length > 6) return false
return /^[A-Z][a-z]+(\s+[A-Za-z]+){1,5}$/.test(text.trim())
}
function createFinding(
node: Node,
sourceFile: SourceFile,
type: 'chinese' | 'english',
source: 'renderer' | 'main',
nodeType: string
): Finding {
return {
file: sourceFile.getFilePath(),
line: sourceFile.getLineAndColumnAtPos(node.getStart()).line,
content: node.getText().slice(0, 100),
type,
source,
nodeType
}
}
function shouldSkipNode(node: Node): boolean {
let current: Node | undefined = node
while (current) {
const parent = current.getParent()
if (!parent) break
if (Node.isImportDeclaration(parent) || Node.isExportDeclaration(parent)) {
return true
}
if (Node.isCallExpression(parent)) {
const callText = parent.getExpression().getText()
if (/^(logger|console)\.(log|error|warn|info|debug|silly|trace|withContext)/.test(callText)) {
return true
}
const callee = parent.getExpression()
if (Node.isIdentifier(callee) && callee.getText() === 't') {
return true
}
}
if (Node.isTypeNode(parent) || Node.isTypeAliasDeclaration(parent) || Node.isInterfaceDeclaration(parent)) {
return true
}
if (Node.isPropertySignature(parent)) {
return true
}
if (Node.isEnumMember(parent)) {
return true
}
// Native language names should stay in native form
if (Node.isVariableDeclaration(parent)) {
const varName = parent.getName()
if (/language|locale/i.test(varName)) {
return true
}
}
current = parent
}
return false
}
function isNonUIString(text: string): boolean {
if (text.length === 0) return true
if (/^\d+$/.test(text)) return true
return false
}
const CODE_CONTEXT = {
cssTags: /^(css|keyframes|injectGlobal|createGlobalStyle|styled\.\w+)$/,
cssNames: /style|css|animation/i,
codeNames: /code|script|python|sql|query|html|template|regex|pattern|shim/i,
jsxAttrs: new Set(['style', 'css']),
execCalls: /\.(executeJavaScript|eval|Function|runPython|runPythonAsync)$/
}
function isInCodeContext(node: Node): boolean {
const parent = node.getParent()
if (!parent) return false
if (Node.isTaggedTemplateExpression(parent)) {
return CODE_CONTEXT.cssTags.test(parent.getTag().getText())
}
if (Node.isVariableDeclaration(parent)) {
const name = parent.getName()
return CODE_CONTEXT.cssNames.test(name) || CODE_CONTEXT.codeNames.test(name)
}
if (Node.isPropertyAssignment(parent)) {
const name = parent.getName()
return CODE_CONTEXT.cssNames.test(name) || CODE_CONTEXT.codeNames.test(name)
}
if (Node.isJsxExpression(parent)) {
const attr = parent.getParent()
if (attr && Node.isJsxAttribute(attr)) {
return CODE_CONTEXT.jsxAttrs.has(attr.getNameNode().getText())
}
}
// Traverse up for code execution calls (handles string concatenation)
let current: Node | undefined = parent
while (current) {
if (Node.isCallExpression(current)) {
if (CODE_CONTEXT.execCalls.test(current.getExpression().getText())) {
return true
}
break
}
if (!Node.isBinaryExpression(current) && !Node.isParenthesizedExpression(current)) {
break
}
current = current.getParent()
}
return false
}
function getJsxElementName(attrNode: Node): string | null {
const parent = attrNode.getParent()
if (!parent) return null
if (Node.isJsxOpeningElement(parent) || Node.isJsxSelfClosingElement(parent)) {
return parent.getTagNameNode().getText()
}
return null
}
function shouldCheckAttribute(attrName: string, elementName: string | null): boolean {
if (UI_ATTRIBUTES.includes(attrName)) {
return true
}
const allowedComponents = CONTEXT_SENSITIVE_ATTRIBUTES[attrName]
if (allowedComponents && elementName) {
return allowedComponents.includes(elementName)
}
return false
}
class HardcodedStringDetector {
private project: Project
constructor() {
this.project = new Project({
skipAddingFilesFromTsConfig: true,
skipFileDependencyResolution: true
})
}
scanFile(filePath: string, source: 'renderer' | 'main'): Finding[] {
const findings: Finding[] = []
try {
const sourceFile = this.project.addSourceFileAtPath(filePath)
sourceFile.forEachDescendant((node) => {
this.checkNode(node, sourceFile, source, findings)
})
this.project.removeSourceFile(sourceFile)
} catch (error) {
console.error(`Error parsing ${filePath}:`, error)
}
return findings
}
private checkNode(node: Node, sourceFile: SourceFile, source: 'renderer' | 'main', findings: Finding[]): void {
if (shouldSkipNode(node)) return
if (Node.isJsxText(node)) {
const text = node.getText().trim()
if (text && hasCJK(text)) {
// Skip SVG internal elements
const parent = node.getParent()
if (parent && (Node.isJsxElement(parent) || Node.isJsxSelfClosingElement(parent))) {
const tagName = Node.isJsxElement(parent)
? parent.getOpeningElement().getTagNameNode().getText()
: parent.getTagNameNode().getText()
if (['title', 'desc', 'text', 'tspan'].includes(tagName)) {
return
}
}
findings.push(createFinding(node, sourceFile, 'chinese', source, 'JsxText'))
}
}
if (Node.isJsxAttribute(node)) {
const attrName = node.getNameNode().getText()
const elementName = getJsxElementName(node)
if (shouldCheckAttribute(attrName, elementName)) {
const initializer = node.getInitializer()
if (initializer && Node.isStringLiteral(initializer)) {
const value = initializer.getLiteralValue()
if (!isNonUIString(value)) {
if (hasCJK(value)) {
findings.push(createFinding(node, sourceFile, 'chinese', source, 'JsxAttribute'))
} else if (source === 'renderer' && hasEnglishUIText(value)) {
findings.push(createFinding(node, sourceFile, 'english', source, 'JsxAttribute'))
}
}
}
}
}
if (Node.isStringLiteral(node)) {
if (isInCodeContext(node)) return
const value = node.getLiteralValue()
if (isNonUIString(value)) return
const parent = node.getParent()
if (parent && Node.isPropertyAssignment(parent)) {
const propName = parent.getName()
if (UI_PROPERTIES.includes(propName)) {
if (hasCJK(value)) {
findings.push(createFinding(node, sourceFile, 'chinese', source, 'PropertyAssignment'))
}
}
}
if (parent && Node.isCallExpression(parent)) {
const callText = parent.getExpression().getText()
if (
/^(window\.toast|message|antdMessage|Modal|notification)\.(success|error|warning|info|confirm)/.test(callText)
) {
if (hasCJK(value)) {
findings.push(createFinding(node, sourceFile, 'chinese', source, 'CallExpression'))
}
}
}
}
if (Node.isTemplateExpression(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
if (isInCodeContext(node)) return
const text = node.getText()
if (hasCJK(text)) {
findings.push(createFinding(node, sourceFile, 'chinese', source, 'TemplateLiteral'))
}
}
}
}
function shouldSkipFile(filePath: string, baseDir: string): boolean {
const relativePath = path.relative(baseDir, filePath)
if (IGNORED_DIRS.some((dir) => relativePath.includes(dir))) {
return true
}
const fileName = path.basename(filePath)
if (
IGNORED_FILES.some((pattern) => {
const regex = new RegExp(pattern.replace('*', '.*'))
return regex.test(fileName)
})
) {
return true
}
return false
}
function scanDirectory(dir: string, source: 'renderer' | 'main', detector: HardcodedStringDetector): Finding[] {
const findings: Finding[] = []
if (!fs.existsSync(dir)) {
return findings
}
const entries = fs.readdirSync(dir, { withFileTypes: true })
for (const entry of entries) {
const fullPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if (!IGNORED_DIRS.includes(entry.name)) {
findings.push(...scanDirectory(fullPath, source, detector))
}
} else if (entry.isFile() && EXTENSIONS.some((ext) => entry.name.endsWith(ext))) {
if (!shouldSkipFile(fullPath, source === 'renderer' ? RENDERER_DIR : MAIN_DIR)) {
findings.push(...detector.scanFile(fullPath, source))
}
}
}
return findings
}
function formatFindings(findings: Finding[]): string {
if (findings.length === 0) {
return '✅ No hardcoded strings found!'
}
const rendererFindings = findings.filter((f) => f.source === 'renderer')
const mainFindings = findings.filter((f) => f.source === 'main')
const chineseFindings = findings.filter((f) => f.type === 'chinese')
const englishFindings = findings.filter((f) => f.type === 'english')
let output = ''
if (rendererFindings.length > 0) {
output += '\n📦 Renderer Process:\n'
output += '-'.repeat(50) + '\n'
const rendererChinese = rendererFindings.filter((f) => f.type === 'chinese')
const rendererEnglish = rendererFindings.filter((f) => f.type === 'english')
if (rendererChinese.length > 0) {
output += '\n⚠ Hardcoded Chinese strings:\n'
rendererChinese.forEach((f) => {
const relativePath = path.relative(RENDERER_DIR, f.file)
output += `\n📍 ${relativePath}:${f.line} [${f.nodeType}]\n`
output += ` ${f.content}\n`
})
}
if (rendererEnglish.length > 0) {
output += '\n⚠ Potential hardcoded English strings:\n'
rendererEnglish.forEach((f) => {
const relativePath = path.relative(RENDERER_DIR, f.file)
output += `\n📍 ${relativePath}:${f.line} [${f.nodeType}]\n`
output += ` ${f.content}\n`
})
}
}
if (mainFindings.length > 0) {
output += '\n📦 Main Process:\n'
output += '-'.repeat(50) + '\n'
const mainChinese = mainFindings.filter((f) => f.type === 'chinese')
if (mainChinese.length > 0) {
output += '\n⚠ Hardcoded Chinese strings:\n'
mainChinese.forEach((f) => {
const relativePath = path.relative(MAIN_DIR, f.file)
output += `\n📍 ${relativePath}:${f.line} [${f.nodeType}]\n`
output += ` ${f.content}\n`
})
}
}
output += '\n' + '='.repeat(50) + '\n'
output += `Total: ${findings.length} potential issues found\n`
output += ` - Renderer: ${rendererFindings.length} (Chinese: ${rendererFindings.filter((f) => f.type === 'chinese').length}, English: ${rendererFindings.filter((f) => f.type === 'english').length})\n`
output += ` - Main: ${mainFindings.length} (Chinese: ${mainFindings.length})\n`
output += ` - Total Chinese: ${chineseFindings.length}\n`
output += ` - Total English: ${englishFindings.length}\n`
return output
}
export function main(): void {
console.log('🔍 Scanning for hardcoded strings using AST analysis...\n')
const detector = new HardcodedStringDetector()
const rendererFindings = scanDirectory(RENDERER_DIR, 'renderer', detector)
const mainFindings = scanDirectory(MAIN_DIR, 'main', detector)
const findings = [...rendererFindings, ...mainFindings]
const output = formatFindings(findings)
console.log(output)
// Strict mode for CI
const strictMode = process.env.I18N_STRICT === 'true' || process.argv.includes('--strict')
const chineseCount = findings.filter((f) => f.type === 'chinese').length
if (strictMode && chineseCount > 0) {
console.error('\n❌ Hardcoded Chinese strings detected in strict mode!')
console.error('Please replace these with i18n keys using the t() function.')
process.exit(1)
}
if (findings.length > 0) {
console.log('\n💡 Tip: Consider replacing these strings with i18n keys.')
console.log(' Use the t() function from react-i18next for translations.')
}
}
export {
HardcodedStringDetector,
hasCJK,
hasEnglishUIText,
isInCodeContext,
isNonUIString,
shouldSkipFile,
shouldSkipNode,
UI_ATTRIBUTES,
UI_PROPERTIES
}
main()