Files
CherryHQ-cherry-studio/scripts/check-hardcoded-strings.ts
亢奋猫 eb2622e9af refactor(ui): remove antd dependency (#16336)
Co-authored-by: gujiaming <52187003+AtomsH4@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: kangfenmao <kangfenmao@qq.com>
Signed-off-by: gujiaming <52187003+AtomsH4@users.noreply.github.com>
2026-06-26 15:49:22 +08:00

479 lines
14 KiB
TypeScript
Raw Permalink 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')
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
}
// Skip native language names in language selectors (SelectItem, Option, etc.)
if (['SelectItem', 'Option', 'MenuItem'].includes(tagName)) {
const jsxElement = Node.isJsxElement(parent) ? parent.getOpeningElement() : parent
const valueAttr = jsxElement.getAttribute('value')
if (valueAttr && Node.isJsxAttribute(valueAttr)) {
const initializer = valueAttr.getInitializer()
if (initializer && Node.isStringLiteral(initializer)) {
const value = initializer.getLiteralValue()
// Language/locale codes like 'zh-CN', 'en-US', 'ja-JP', etc.
if (/^[a-z]{2}(-[A-Z]{2})?$/.test(value)) {
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|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()