import fs from 'fs'; import path from 'path'; import { ko } from '../src/i18n/locales/ko'; import { en } from '../src/i18n/locales/en'; /** * 전역 설정 및 상수 */ const TARGET_DIR = path.join(__dirname, '../tests'); const IGNORE_FILES = ['node_modules', '.git']; const LOCALES = { ko, en }; type I18nEntry = { key: string; locales: string[] }; const i18nValueToKey = new Map(); /** * i18n 객체를 평탄화하여 '값 -> 키' 매핑을 생성합니다. */ function walk(obj: any, prefix = '', locale = '') { for (const [key, value] of Object.entries(obj)) { const fullKey = prefix ? `${prefix}.${key}` : key; if (typeof value === 'string') { const entry = i18nValueToKey.get(value); if (entry) { if (!entry.locales.includes(locale)) entry.locales.push(locale); } else { i18nValueToKey.set(value, { key: fullKey, locales: [locale] }); } } else if (typeof value === 'object' && value !== null) { walk(value, fullKey, locale); } } } // 로딩 for (const [locale, data] of Object.entries(LOCALES)) { walk(data, '', locale); } /** * 테스트 파일을 재귀적으로 탐색합니다. */ function getFiles(dir: string): string[] { const results: string[] = []; if (!fs.existsSync(dir)) return results; const list = fs.readdirSync(dir); for (const file of list) { if (IGNORE_FILES.some(ignore => file.includes(ignore))) continue; const fullPath = path.join(dir, file); const stat = fs.statSync(fullPath); if (stat.isDirectory()) { results.push(...getFiles(fullPath)); } else if (file.endsWith('.test.ts') || file.endsWith('.spec.ts') || (dir.includes('tests') && file.endsWith('.ts'))) { results.push(fullPath); } } return results; } /** * 파일 내에서 하드코딩된 i18n 값을 찾습니다. */ function checkFile(filePath: string) { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.split('\n'); let matchCount = 0; lines.forEach((line, index) => { // 0. 무시 주석 체크 if (line.includes('i18n-ignore')) return; // 따옴표로 둘러싸인 모든 문자열을 찾습니다. // 인덱스를 추적하기 위해 수동으로 문자열을 찾습니다. const regex = /(['"`])(.*?)\1/g; let match; while ((match = regex.exec(line)) !== null) { const fullMatch = match[0]; const val = match[2]; if (i18nValueToKey.has(val)) { const info = i18nValueToKey.get(val)!; // 1. 만약 문자열이 i18n 키 자체라면 (점 포함) 무시합니다. if (val === info.key || (val.includes('.') && !val.includes(' '))) continue; // 2. t(..., 'key') 에서 'key'가 값과 같은 경우 무시 (매우 드문 경우) // t() 호출 안에 있는지 대략적으로 체크 const linePrefix = line.substring(0, match.index); if (linePrefix.trim().endsWith('t(') || linePrefix.includes('t(')) { // 호출 인자로 보인다면 패스 (단순화된 로직) if (val === info.key) continue; } console.log(`[FOUND] ${path.relative(process.cwd(), filePath)}:${index + 1}`); console.log(` - Hardcoded: ${fullMatch}`); console.log(` - Suggested: t(locale, '${info.key}')`); console.log(` - Values: "${val}"`); console.log(` - Locales: ${info.locales.join(', ')}`); console.log(''); matchCount++; } } }); return matchCount; } // 실행 console.log('--- i18n Reference Check in Tests ---'); console.log(`Total i18n values loaded: ${i18nValueToKey.size}`); const files = getFiles(TARGET_DIR); console.log(`Checking ${files.length} test files...`); let totalMatch = 0; files.forEach(file => { totalMatch += checkFile(file); }); if (totalMatch === 0) { console.log('✅ No hardcoded i18n values found in tests.'); } else { console.log(`❌ Found ${totalMatch} violations.`); }