Kord/scripts/check-i18n-tests.ts

126 lines
3.9 KiB
TypeScript

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<string, I18nEntry>();
/**
* 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.`);
}