feat: 支持i18n 相关检查
This commit is contained in:
parent
99e43fce22
commit
bafe3e0c36
|
|
@ -0,0 +1,9 @@
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
/**
|
||||||
|
* 判断节点是否为 t 函数调用
|
||||||
|
* @param node ts.Node
|
||||||
|
* @param typeChecker ts.TypeChecker
|
||||||
|
* @returns boolean
|
||||||
|
* t: (key: string, params?: object | undefined) => string
|
||||||
|
*/
|
||||||
|
export declare const isTCall: (node: ts.Node, typeChecker: ts.TypeChecker, modules: string[]) => boolean;
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
"use strict";
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.isTCall = void 0;
|
||||||
|
const tslib_1 = require("tslib");
|
||||||
|
const ts = tslib_1.__importStar(require("typescript"));
|
||||||
|
// 缓存结构:WeakMap<Node, Map<modulesKey, boolean>>
|
||||||
|
const isTCallCache = new WeakMap();
|
||||||
|
/**
|
||||||
|
* 生成 modules 的缓存 key
|
||||||
|
*/
|
||||||
|
function getModulesCacheKey(modules) {
|
||||||
|
return modules.sort().join('|');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 判断节点是否为 t 函数调用
|
||||||
|
* @param node ts.Node
|
||||||
|
* @param typeChecker ts.TypeChecker
|
||||||
|
* @returns boolean
|
||||||
|
* t: (key: string, params?: object | undefined) => string
|
||||||
|
*/
|
||||||
|
const isTCall = (node, typeChecker, modules) => {
|
||||||
|
// 缓存查询
|
||||||
|
const modulesCacheKey = getModulesCacheKey(modules);
|
||||||
|
let nodeCache = isTCallCache.get(node);
|
||||||
|
if (nodeCache) {
|
||||||
|
const cachedResult = nodeCache.get(modulesCacheKey);
|
||||||
|
if (cachedResult !== undefined) {
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!(ts.isCallExpression(node) && ts.isIdentifier(node.expression))) {
|
||||||
|
// 缓存 false 结果
|
||||||
|
if (!nodeCache) {
|
||||||
|
nodeCache = new Map();
|
||||||
|
isTCallCache.set(node, nodeCache);
|
||||||
|
}
|
||||||
|
nodeCache.set(modulesCacheKey, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const result = doIsTCallDirect(node, typeChecker, modules);
|
||||||
|
// 缓存结果
|
||||||
|
if (!nodeCache) {
|
||||||
|
nodeCache = new Map();
|
||||||
|
isTCallCache.set(node, nodeCache);
|
||||||
|
}
|
||||||
|
nodeCache.set(modulesCacheKey, result);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
exports.isTCall = isTCall;
|
||||||
|
const doIsTCallDirect = (node, typeChecker, modules) => {
|
||||||
|
if (!(ts.isCallExpression(node) && ts.isIdentifier(node.expression))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const symbol = typeChecker.getSymbolAtLocation(node.expression);
|
||||||
|
if (!symbol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const declarations = symbol.getDeclarations();
|
||||||
|
if (!declarations || declarations.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const decl of declarations) {
|
||||||
|
const declType = typeChecker.getTypeAtLocation(decl);
|
||||||
|
const signatures = declType.getCallSignatures();
|
||||||
|
for (const sig of signatures) {
|
||||||
|
// 检查这个类型是否来自指定模块
|
||||||
|
if (!isTypeFromModules(declType, modules)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 检查返回类型是否为 string
|
||||||
|
const returnType = typeChecker.getReturnTypeOfSignature(sig);
|
||||||
|
if ((returnType.flags & ts.TypeFlags.String) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const params = sig.getParameters();
|
||||||
|
if (params.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 检查第一个参数是否为 string
|
||||||
|
const firstParamType = typeChecker.getTypeOfSymbolAtLocation(params[0], decl);
|
||||||
|
if ((firstParamType.flags & ts.TypeFlags.String) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 如果只有一个参数,符合 (key: string) => string
|
||||||
|
if (params.length === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 检查第二个参数
|
||||||
|
if (params.length >= 2) {
|
||||||
|
const secondParam = params[1];
|
||||||
|
const secondParamType = typeChecker.getTypeOfSymbolAtLocation(secondParam, decl);
|
||||||
|
// 检查第二个参数是否为 object 或 object | undefined
|
||||||
|
if (isObjectOrObjectUnion(secondParamType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 检查类型是否为 object 或 object | undefined
|
||||||
|
* @param type ts.Type
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function isObjectOrObjectUnion(type) {
|
||||||
|
// 如果是联合类型
|
||||||
|
if (type.isUnion()) {
|
||||||
|
// 检查联合类型中是否包含 object 类型
|
||||||
|
// 允许 object | undefined 的组合
|
||||||
|
let hasObject = false;
|
||||||
|
let hasOnlyObjectAndUndefined = true;
|
||||||
|
for (const t of type.types) {
|
||||||
|
if ((t.flags & ts.TypeFlags.Object) !== 0 || (t.flags & ts.TypeFlags.NonPrimitive) !== 0) {
|
||||||
|
hasObject = true;
|
||||||
|
}
|
||||||
|
else if ((t.flags & ts.TypeFlags.Undefined) === 0) {
|
||||||
|
// 如果既不是 object 也不是 undefined,则不符合
|
||||||
|
hasOnlyObjectAndUndefined = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hasObject && hasOnlyObjectAndUndefined;
|
||||||
|
}
|
||||||
|
// 如果是单一类型,检查是否为 object
|
||||||
|
return (type.flags & ts.TypeFlags.Object) !== 0 || (type.flags & ts.TypeFlags.NonPrimitive) !== 0;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检查声明是否来自指定的模块
|
||||||
|
* @param decl ts.Declaration
|
||||||
|
* @param modules 模块名称列表
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function isFromModules(decl, modules) {
|
||||||
|
const sourceFile = decl.getSourceFile();
|
||||||
|
if (!sourceFile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const fileName = sourceFile.fileName;
|
||||||
|
// 检查文件路径是否包含指定的模块
|
||||||
|
return modules.some(moduleName => {
|
||||||
|
return fileName.includes(moduleName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 检查类型是否来自指定模块
|
||||||
|
* @param type ts.Type
|
||||||
|
* @param modules 模块名称列表
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function isTypeFromModules(type, modules) {
|
||||||
|
const symbol = type.getSymbol();
|
||||||
|
if (!symbol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const declarations = symbol.getDeclarations();
|
||||||
|
if (!declarations || declarations.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return declarations.some(decl => isFromModules(decl, modules));
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,10 @@ interface OakBuildChecksConfig {
|
||||||
targetModules?: string[];
|
targetModules?: string[];
|
||||||
filePatterns?: string[];
|
filePatterns?: string[];
|
||||||
};
|
};
|
||||||
|
locale?: {
|
||||||
|
checkI18nKeys?: boolean;
|
||||||
|
tFunctionModules?: string[];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
interface CustomDiagnostic {
|
interface CustomDiagnostic {
|
||||||
file: ts.SourceFile;
|
file: ts.SourceFile;
|
||||||
|
|
@ -36,6 +40,6 @@ export type CompileOptions = {
|
||||||
* @param customConfig 自定义配置
|
* @param customConfig 自定义配置
|
||||||
* @returns CustomDiagnostic[] 自定义诊断列表
|
* @returns CustomDiagnostic[] 自定义诊断列表
|
||||||
*/
|
*/
|
||||||
export declare function performCustomChecks(program: ts.Program, typeChecker: ts.TypeChecker, customConfig: OakBuildChecksConfig): CustomDiagnostic[];
|
export declare function performCustomChecks(pwd: string, program: ts.Program, typeChecker: ts.TypeChecker, customConfig: OakBuildChecksConfig): CustomDiagnostic[];
|
||||||
export declare const build: (pwd: string, args: any[]) => void;
|
export declare const build: (pwd: string, args: any[]) => void;
|
||||||
export {};
|
export {};
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,13 @@ const ts = tslib_1.__importStar(require("typescript"));
|
||||||
const path = tslib_1.__importStar(require("path"));
|
const path = tslib_1.__importStar(require("path"));
|
||||||
const fs = tslib_1.__importStar(require("fs"));
|
const fs = tslib_1.__importStar(require("fs"));
|
||||||
const glob_1 = require("../utils/glob");
|
const glob_1 = require("../utils/glob");
|
||||||
|
const identifier_1 = require("./identifier");
|
||||||
|
const lodash_1 = require("lodash");
|
||||||
const ARRAY_METHODS = ['map', 'forEach', 'filter', 'reduce', 'some', 'every', 'find', 'findIndex', 'flatMap'];
|
const ARRAY_METHODS = ['map', 'forEach', 'filter', 'reduce', 'some', 'every', 'find', 'findIndex', 'flatMap'];
|
||||||
const ARRAY_TRANSFORM_METHODS = ['map', 'filter', 'flatMap'];
|
const ARRAY_TRANSFORM_METHODS = ['map', 'filter', 'flatMap'];
|
||||||
const PROMISE_METHODS = ['then', 'catch', 'finally'];
|
const PROMISE_METHODS = ['then', 'catch', 'finally'];
|
||||||
const PROMISE_STATIC_METHODS = ['all', 'race', 'allSettled', 'any'];
|
const PROMISE_STATIC_METHODS = ['all', 'race', 'allSettled', 'any'];
|
||||||
|
const LOCALE_FILE_NAMES = ['zh_CN.json', 'zh-CN.json'];
|
||||||
// 判断是否是函数类声明
|
// 判断是否是函数类声明
|
||||||
const isFunctionLikeDeclaration = (node) => {
|
const isFunctionLikeDeclaration = (node) => {
|
||||||
// FunctionDeclaration | MethodDeclaration | GetAccessorDeclaration | SetAccessorDeclaration | ConstructorDeclaration | FunctionExpression | ArrowFunction;
|
// FunctionDeclaration | MethodDeclaration | GetAccessorDeclaration | SetAccessorDeclaration | ConstructorDeclaration | FunctionExpression | ArrowFunction;
|
||||||
|
|
@ -165,9 +168,9 @@ const isIdentifierWithSymbol = (node, symbol, typeChecker) => {
|
||||||
* @param customConfig 自定义配置
|
* @param customConfig 自定义配置
|
||||||
* @returns boolean
|
* @returns boolean
|
||||||
*/
|
*/
|
||||||
const isFileInCheckScope = (fileName, customConfig) => {
|
const isFileInCheckScope = (pwd, fileName, customConfig) => {
|
||||||
const patterns = customConfig.context?.filePatterns || ['**/*.ts', '**/*.tsx'];
|
const patterns = customConfig.context?.filePatterns || ['**/*.ts', '**/*.tsx'];
|
||||||
const normalizedFileName = path.normalize(path.relative(process.cwd(), fileName)).replace(/\\/g, '/');
|
const normalizedFileName = path.normalize(path.relative(pwd, fileName)).replace(/\\/g, '/');
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
if ((0, glob_1.matchGlobPattern)(normalizedFileName, pattern.replace(/\\/g, '/'))) {
|
if ((0, glob_1.matchGlobPattern)(normalizedFileName, pattern.replace(/\\/g, '/'))) {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -216,24 +219,14 @@ function parseArgs(pargs) {
|
||||||
}
|
}
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
// 读取自定义配置
|
const getContextLocationText = (pwd, callNode) => {
|
||||||
function readCustomConfig(configPath) {
|
|
||||||
const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
|
|
||||||
if (configFile.error) {
|
|
||||||
return {}; // 返回默认配置
|
|
||||||
}
|
|
||||||
const config = configFile.config;
|
|
||||||
// 返回自定义配置,如果不存在则返回默认值
|
|
||||||
return config.oakBuildChecks || {};
|
|
||||||
}
|
|
||||||
const getContextLocationText = (callNode) => {
|
|
||||||
const sourceFile = callNode.getSourceFile();
|
const sourceFile = callNode.getSourceFile();
|
||||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(callNode.getStart());
|
const { line, character } = sourceFile.getLineAndCharacterOfPosition(callNode.getStart());
|
||||||
// const callText = callNode.getText(sourceFile);
|
// const callText = callNode.getText(sourceFile);
|
||||||
// return `${callText}@${path.relative(process.cwd(), sourceFile.fileName)}:${line + 1}:${character + 1}`;
|
// return `${callText}@${path.relative(process.cwd(), sourceFile.fileName)}:${line + 1}:${character + 1}`;
|
||||||
return `${path.relative(process.cwd(), sourceFile.fileName)}:${line + 1}:${character + 1}`;
|
return `${path.relative(pwd, sourceFile.fileName)}:${line + 1}:${character + 1}`;
|
||||||
};
|
};
|
||||||
function printDiagnostic(diagnostic, index) {
|
function printDiagnostic(pwd, diagnostic, index) {
|
||||||
const isCustom = 'callChain' in diagnostic;
|
const isCustom = 'callChain' in diagnostic;
|
||||||
if (diagnostic.file && diagnostic.start !== undefined) {
|
if (diagnostic.file && diagnostic.start !== undefined) {
|
||||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||||
|
|
@ -243,7 +236,7 @@ function printDiagnostic(diagnostic, index) {
|
||||||
const categoryColor = isError ? colors.red : colors.yellow;
|
const categoryColor = isError ? colors.red : colors.yellow;
|
||||||
// 主要错误信息
|
// 主要错误信息
|
||||||
console.log(`\n${colors.cyan}┌─ Issue #${index + 1}${colors.reset}`);
|
console.log(`\n${colors.cyan}┌─ Issue #${index + 1}${colors.reset}`);
|
||||||
console.log(`${colors.cyan}│${colors.reset} ${colors.cyan}${path.relative(process.cwd(), diagnostic.file.fileName)}${colors.reset}:${colors.yellow}${line + 1}${colors.reset}:${colors.yellow}${character + 1}${colors.reset}`);
|
console.log(`${colors.cyan}│${colors.reset} ${colors.cyan}${path.relative(pwd, diagnostic.file.fileName)}${colors.reset}:${colors.yellow}${line + 1}${colors.reset}:${colors.yellow}${character + 1}${colors.reset}`);
|
||||||
console.log(`${colors.cyan}│${colors.reset} ${categoryColor}${category}${colors.reset} ${colors.gray}TS${diagnostic.code}${colors.reset}: ${message}`);
|
console.log(`${colors.cyan}│${colors.reset} ${categoryColor}${category}${colors.reset} ${colors.gray}TS${diagnostic.code}${colors.reset}: ${message}`);
|
||||||
// 显示代码片段
|
// 显示代码片段
|
||||||
const sourceFile = diagnostic.file;
|
const sourceFile = diagnostic.file;
|
||||||
|
|
@ -284,7 +277,7 @@ function printDiagnostic(diagnostic, index) {
|
||||||
// 显示实际的context调用位置
|
// 显示实际的context调用位置
|
||||||
if (customDiag.contextCallNode) {
|
if (customDiag.contextCallNode) {
|
||||||
console.log(`${colors.cyan}│${colors.reset}`);
|
console.log(`${colors.cyan}│${colors.reset}`);
|
||||||
console.log(`${colors.cyan}│${colors.reset} ${colors.red}→ ${getContextLocationText(customDiag.contextCallNode)}${colors.reset}`);
|
console.log(`${colors.cyan}│${colors.reset} ${colors.red}→ ${getContextLocationText(pwd, customDiag.contextCallNode)}${colors.reset}`);
|
||||||
}
|
}
|
||||||
// 显示检测原因
|
// 显示检测原因
|
||||||
if (customDiag.reason) {
|
if (customDiag.reason) {
|
||||||
|
|
@ -327,13 +320,12 @@ function printDiagnostic(diagnostic, index) {
|
||||||
* @param customConfig 自定义配置
|
* @param customConfig 自定义配置
|
||||||
* @returns CustomDiagnostic[] 自定义诊断列表
|
* @returns CustomDiagnostic[] 自定义诊断列表
|
||||||
*/
|
*/
|
||||||
function performCustomChecks(program, typeChecker, customConfig) {
|
function performCustomChecks(pwd, program, typeChecker, customConfig) {
|
||||||
const diagnostics = [];
|
const diagnostics = [];
|
||||||
// 如果自定义检查被禁用,直接返回
|
const enableAsyncContextCheck = customConfig.context?.checkAsyncContext !== false;
|
||||||
if (!customConfig.context?.checkAsyncContext) {
|
const enableTCheck = customConfig.locale?.checkI18nKeys !== false;
|
||||||
console.log('Custom AsyncContext checks are disabled.');
|
console.log(`Custom AsyncContext checks are ${enableAsyncContextCheck ? colors.green + 'enabled' + colors.reset : colors.gray + 'disabled' + colors.reset}.`);
|
||||||
return diagnostics;
|
console.log(`Custom i18n key checks are ${enableTCheck ? colors.green + 'enabled' + colors.reset : colors.gray + 'disabled' + colors.reset}.`);
|
||||||
}
|
|
||||||
// 数据结构定义
|
// 数据结构定义
|
||||||
const functionsWithContextCalls = new Set();
|
const functionsWithContextCalls = new Set();
|
||||||
const functionDeclarations = new Map();
|
const functionDeclarations = new Map();
|
||||||
|
|
@ -353,6 +345,8 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
||||||
const ignoreCommentNodes = new Set();
|
const ignoreCommentNodes = new Set();
|
||||||
// 符号缓存
|
// 符号缓存
|
||||||
const symbolCache = new Map();
|
const symbolCache = new Map();
|
||||||
|
// t函数调用符号缓存
|
||||||
|
const tFunctionSymbolCache = new Set();
|
||||||
const getSymbolCached = (node) => {
|
const getSymbolCached = (node) => {
|
||||||
if (symbolCache.has(node)) {
|
if (symbolCache.has(node)) {
|
||||||
return symbolCache.get(node);
|
return symbolCache.get(node);
|
||||||
|
|
@ -780,6 +774,7 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
||||||
}
|
}
|
||||||
// 处理调用表达式 - 合并原来的两个逻辑
|
// 处理调用表达式 - 合并原来的两个逻辑
|
||||||
if (ts.isCallExpression(node)) {
|
if (ts.isCallExpression(node)) {
|
||||||
|
if (enableAsyncContextCheck) {
|
||||||
// 检查 context 调用
|
// 检查 context 调用
|
||||||
if (ts.isPropertyAccessExpression(node.expression)) {
|
if (ts.isPropertyAccessExpression(node.expression)) {
|
||||||
const objectType = typeChecker.getTypeAtLocation(node.expression.expression);
|
const objectType = typeChecker.getTypeAtLocation(node.expression.expression);
|
||||||
|
|
@ -816,6 +811,10 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
||||||
localCallsToCheck.push(node);
|
localCallsToCheck.push(node);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (enableTCheck && (0, identifier_1.isTCall)(node, typeChecker, customConfig.locale?.tFunctionModules || ['oak-frontend-base'])) {
|
||||||
|
tFunctionSymbolCache.add(node);
|
||||||
|
}
|
||||||
|
}
|
||||||
ts.forEachChild(node, visit);
|
ts.forEachChild(node, visit);
|
||||||
};
|
};
|
||||||
visit(sourceFile);
|
visit(sourceFile);
|
||||||
|
|
@ -1536,7 +1535,7 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 文件名是否符合检查范围
|
// 文件名是否符合检查范围
|
||||||
if (!isFileInCheckScope(sourceFile.fileName, customConfig)) {
|
if (!isFileInCheckScope(pwd, sourceFile.fileName, customConfig)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
preprocessIgnoreComments(sourceFile); // 预处理注释
|
preprocessIgnoreComments(sourceFile); // 预处理注释
|
||||||
|
|
@ -1552,13 +1551,653 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
||||||
propagateContextMarks();
|
propagateContextMarks();
|
||||||
checkDirectContextCalls(); // 检查直接调用
|
checkDirectContextCalls(); // 检查直接调用
|
||||||
checkIndirectCalls(); // 检查间接调用(使用缓存的调用列表)
|
checkIndirectCalls(); // 检查间接调用(使用缓存的调用列表)
|
||||||
return diagnostics;
|
const i18nDiagnostics = checkI18nKeys(pwd, tFunctionSymbolCache, program, typeChecker);
|
||||||
|
return diagnostics.concat(i18nDiagnostics);
|
||||||
}
|
}
|
||||||
const compile = (options) => {
|
const loadI18nData = (dirPath) => {
|
||||||
// 读取自定义配置
|
// 尝试加载 LOCALE_FILE_NAMES 中的文件
|
||||||
const customConfig = readCustomConfig(options.project);
|
for (const fileName of LOCALE_FILE_NAMES) {
|
||||||
|
const filePath = path.join(dirPath, fileName);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(fileContent);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Error reading or parsing i18n file: ${filePath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 检查 key 是否存在于 i18n 数据中
|
||||||
|
* @param i18nData i18n 数据对象
|
||||||
|
* @param key i18n key,支持点号分隔的嵌套路径
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
const isKeyExistsInI18nData = (i18nData, key) => {
|
||||||
|
if (!i18nData) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 辅助递归函数
|
||||||
|
const checkPath = (obj, remainingKey) => {
|
||||||
|
if (!obj || typeof obj !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// 如果剩余key完整存在于当前对象,直接返回true
|
||||||
|
if (obj.hasOwnProperty(remainingKey)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// 尝试所有可能的分割点
|
||||||
|
for (let i = 1; i <= remainingKey.length; i++) {
|
||||||
|
const firstPart = remainingKey.substring(0, i);
|
||||||
|
const restPart = remainingKey.substring(i + 1); // +1 跳过点号
|
||||||
|
// 如果当前部分存在且后面还有内容(有点号分隔)
|
||||||
|
if (obj.hasOwnProperty(firstPart) && i < remainingKey.length && remainingKey[i] === '.') {
|
||||||
|
// 递归检查剩余部分
|
||||||
|
if (checkPath(obj[firstPart], restPart)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
return checkPath(i18nData, key);
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 分析表达式的类型信息
|
||||||
|
*/
|
||||||
|
const analyzeExpressionType = (expr, typeChecker) => {
|
||||||
|
const result = {
|
||||||
|
isLiteralUnion: false,
|
||||||
|
literalValues: [],
|
||||||
|
isString: false,
|
||||||
|
isNullable: false,
|
||||||
|
hasNonNullAssertion: false,
|
||||||
|
originalType: typeChecker.getTypeAtLocation(expr)
|
||||||
|
};
|
||||||
|
// 检查是否有非空断言
|
||||||
|
let actualExpr = expr;
|
||||||
|
if (ts.isNonNullExpression(expr)) {
|
||||||
|
result.hasNonNullAssertion = true;
|
||||||
|
actualExpr = expr.expression;
|
||||||
|
}
|
||||||
|
const type = typeChecker.getTypeAtLocation(actualExpr);
|
||||||
|
// 检查是否是联合类型
|
||||||
|
if (type.isUnion()) {
|
||||||
|
const nonNullTypes = [];
|
||||||
|
for (const subType of type.types) {
|
||||||
|
// 检查是否包含 null 或 undefined
|
||||||
|
if (subType.flags & ts.TypeFlags.Null || subType.flags & ts.TypeFlags.Undefined) {
|
||||||
|
result.isNullable = true;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nonNullTypes.push(subType);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果有非空断言,忽略 nullable
|
||||||
|
if (result.hasNonNullAssertion) {
|
||||||
|
result.isNullable = false;
|
||||||
|
}
|
||||||
|
// 检查非 null 类型是否都是字面量
|
||||||
|
if (nonNullTypes.length > 0) {
|
||||||
|
const allLiterals = nonNullTypes.every(t => t.isStringLiteral() || (t.flags & ts.TypeFlags.StringLiteral));
|
||||||
|
if (allLiterals) {
|
||||||
|
result.isLiteralUnion = true;
|
||||||
|
result.literalValues = nonNullTypes.map(t => {
|
||||||
|
if (t.isStringLiteral()) {
|
||||||
|
return t.value;
|
||||||
|
}
|
||||||
|
return typeChecker.typeToString(t).replace(/['"]/g, '');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 单一类型
|
||||||
|
if (type.flags & ts.TypeFlags.Null || type.flags & ts.TypeFlags.Undefined) {
|
||||||
|
result.isNullable = true;
|
||||||
|
if (result.hasNonNullAssertion) {
|
||||||
|
result.isNullable = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (type.isStringLiteral() || (type.flags & ts.TypeFlags.StringLiteral)) {
|
||||||
|
result.isLiteralUnion = true;
|
||||||
|
result.literalValues = [
|
||||||
|
type.isStringLiteral()
|
||||||
|
? type.value
|
||||||
|
: typeChecker.typeToString(type).replace(/['"]/g, '')
|
||||||
|
];
|
||||||
|
}
|
||||||
|
else if (type.flags & ts.TypeFlags.String) {
|
||||||
|
result.isString = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
const checkI18nKeys = (pwd, callSet, program, typeChecker) => {
|
||||||
|
const diagnostics = [];
|
||||||
|
const addDiagnostic = (node, messageText, code, options) => {
|
||||||
|
const sourceFile = node.getSourceFile();
|
||||||
|
diagnostics.push({
|
||||||
|
file: sourceFile,
|
||||||
|
start: node.getStart(sourceFile),
|
||||||
|
length: node.getWidth(sourceFile),
|
||||||
|
messageText,
|
||||||
|
category: ts.DiagnosticCategory.Warning,
|
||||||
|
code,
|
||||||
|
reason: options?.reason,
|
||||||
|
reasonDetails: options?.reasonDetails
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const groupedCalls = new Map(); // 按照文件路径分组,这样方便后面去读取 i18n 文件
|
||||||
|
const commonLocaleCache = {}; // 公共缓存,避免重复读取文件
|
||||||
|
const entityLocaleCache = {}; // 实体缓存,避免重复读取文件
|
||||||
|
const commonLocaleKeyRegex = /^([a-zA-Z0-9_.-]+)::/; // 以语言代码开头,后面跟两个冒号
|
||||||
|
const entityLocaleKeyRegex = /^([a-zA-Z0-9_.-]+):/; // 以实体代码开头,后面跟一个冒号
|
||||||
|
callSet.forEach(callNode => {
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const filePath = path.normalize(path.relative(pwd, sourceFile.fileName));
|
||||||
|
if (!groupedCalls.has(filePath)) {
|
||||||
|
groupedCalls.set(filePath, []);
|
||||||
|
}
|
||||||
|
groupedCalls.get(filePath).push(callNode);
|
||||||
|
});
|
||||||
|
const getCommonLocaleData = (namespace) => {
|
||||||
|
if (commonLocaleCache.hasOwnProperty(namespace)) {
|
||||||
|
return commonLocaleCache[namespace];
|
||||||
|
}
|
||||||
|
// 尝试加载公共 i18n 文件,在pwd/src/locales/${namespace}/???.json
|
||||||
|
const localeDir = path.join(pwd, 'src', 'locales', namespace);
|
||||||
|
if (fs.existsSync(localeDir) && fs.statSync(localeDir).isDirectory()) {
|
||||||
|
const localeData = {};
|
||||||
|
LOCALE_FILE_NAMES.forEach(fileName => {
|
||||||
|
const filePath = path.join(localeDir, fileName);
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
try {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(fileContent);
|
||||||
|
Object.assign(localeData, data);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Error reading or parsing common i18n file: ${filePath}`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
commonLocaleCache[namespace] = localeData;
|
||||||
|
return localeData;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
commonLocaleCache[namespace] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const getEntityLocaleData = (entity) => {
|
||||||
|
if (entityLocaleCache.hasOwnProperty(entity)) {
|
||||||
|
return entityLocaleCache[entity];
|
||||||
|
}
|
||||||
|
// 尝试加载实体 i18n 文件,在pwd/src/oak-app-domain/${大学开头entity}/locales/zh_CN.json 这里一定是zh_CN.json
|
||||||
|
const entityDir = path.join(pwd, 'src', 'oak-app-domain', (0, lodash_1.upperFirst)(entity), 'locales');
|
||||||
|
if (fs.existsSync(entityDir) && fs.statSync(entityDir).isDirectory()) {
|
||||||
|
const localeData = {};
|
||||||
|
try {
|
||||||
|
const filePath = path.join(entityDir, 'zh_CN.json');
|
||||||
|
if (fs.existsSync(filePath)) {
|
||||||
|
const fileContent = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const data = JSON.parse(fileContent);
|
||||||
|
Object.assign(localeData, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
console.error(`Error reading or parsing entity i18n file: ${entityDir}`, error);
|
||||||
|
}
|
||||||
|
entityLocaleCache[entity] = localeData;
|
||||||
|
return localeData;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
entityLocaleCache[entity] = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 生成模板字符串的所有可能组合
|
||||||
|
*/
|
||||||
|
const generateKeyVariants = (head, spans) => {
|
||||||
|
// 如果任何一个 span 的 values 为 null(表示无法确定),返回 null
|
||||||
|
if (spans.some(span => span.values === null)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
const generate = (index, current) => {
|
||||||
|
if (index >= spans.length) {
|
||||||
|
results.push(current);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const span = spans[index];
|
||||||
|
const values = span.values;
|
||||||
|
for (const value of values) {
|
||||||
|
generate(index + 1, current + value + span.text);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
generate(0, head);
|
||||||
|
return results;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 检查模板表达式中的 i18n key
|
||||||
|
*/
|
||||||
|
const checkTemplateExpressionKey = (templateExpr, callNode, i18nData, localePath, typeChecker, addDiagnostic, checkWithCommonString) => {
|
||||||
|
const head = templateExpr.head.text;
|
||||||
|
const spans = [];
|
||||||
|
let hasUncheckableSpan = false;
|
||||||
|
let hasNullableSpan = false;
|
||||||
|
const spanAnalyses = [];
|
||||||
|
// 分析每个模板片段
|
||||||
|
for (const span of templateExpr.templateSpans) {
|
||||||
|
const analysis = analyzeExpressionType(span.expression, typeChecker);
|
||||||
|
spanAnalyses.push({ span, analysis });
|
||||||
|
if (analysis.isLiteralUnion) {
|
||||||
|
// 字面量联合类型,可以检查
|
||||||
|
spans.push({
|
||||||
|
text: span.literal.text,
|
||||||
|
values: analysis.literalValues
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (analysis.isString) {
|
||||||
|
// string 类型,无法检查
|
||||||
|
hasUncheckableSpan = true;
|
||||||
|
spans.push({
|
||||||
|
text: span.literal.text,
|
||||||
|
values: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (analysis.isNullable) {
|
||||||
|
// 可能为 null/undefined
|
||||||
|
hasNullableSpan = true;
|
||||||
|
spans.push({
|
||||||
|
text: span.literal.text,
|
||||||
|
values: [''] // 空字符串表示 null/undefined 的情况
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 其他类型,尝试获取字符串表示
|
||||||
|
const typeString = typeChecker.typeToString(analysis.originalType);
|
||||||
|
hasUncheckableSpan = true;
|
||||||
|
spans.push({
|
||||||
|
text: span.literal.text,
|
||||||
|
values: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 生成所有可能的 key 组合
|
||||||
|
const keyVariants = generateKeyVariants(head, spans);
|
||||||
|
if (keyVariants === null) {
|
||||||
|
// 有无法确定的占位符
|
||||||
|
if (hasUncheckableSpan) {
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const { line } = sourceFile.getLineAndCharacterOfPosition(templateExpr.getStart());
|
||||||
|
addDiagnostic(callNode, `i18n key 使用了模板字符串,但包含无法确定范围的占位符变量(类型为 string),无法进行完整检查。`, 9203, {
|
||||||
|
reason: 'UncheckableTemplate',
|
||||||
|
reasonDetails: spanAnalyses
|
||||||
|
.filter(({ analysis }) => analysis.isString)
|
||||||
|
.map(({ span }) => {
|
||||||
|
const exprText = span.expression.getText(sourceFile);
|
||||||
|
return `✘ 占位符 \${${exprText}} 的类型为 string,范围太大`;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 检查所有可能的 key
|
||||||
|
const missingKeys = [];
|
||||||
|
const foundKeys = [];
|
||||||
|
for (const key of keyVariants) {
|
||||||
|
const exists = isKeyExistsInI18nData(i18nData, key) ||
|
||||||
|
checkWithCommonStringExists(i18nData, key);
|
||||||
|
if (exists) {
|
||||||
|
foundKeys.push(key);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
missingKeys.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 如果有 nullable 的情况,需要特别处理
|
||||||
|
if (hasNullableSpan) {
|
||||||
|
const nullableSpans = spanAnalyses.filter(({ analysis }) => analysis.isNullable);
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const details = [];
|
||||||
|
details.push(`¡ 模板字符串包含可能为 null/undefined 的占位符`);
|
||||||
|
nullableSpans.forEach(({ span, analysis }) => {
|
||||||
|
const exprText = span.expression.getText(sourceFile);
|
||||||
|
const hasAssertion = analysis.hasNonNullAssertion ? ' (有非空断言)' : '';
|
||||||
|
details.push(` ${analysis.hasNonNullAssertion ? '✓' : '✘'} \${${exprText}}${hasAssertion}`);
|
||||||
|
});
|
||||||
|
details.push('');
|
||||||
|
details.push('需要检查的 key 变体:');
|
||||||
|
missingKeys.forEach(key => {
|
||||||
|
details.push(` ✘ "${key}" - 未找到`);
|
||||||
|
});
|
||||||
|
foundKeys.forEach(key => {
|
||||||
|
details.push(` ✓ "${key}" - 已找到`);
|
||||||
|
});
|
||||||
|
addDiagnostic(callNode, `i18n key 模板字符串的某些变体未找到: ${missingKeys.join(', ')}`, 9200, {
|
||||||
|
reason: 'MissingTemplateVariants',
|
||||||
|
reasonDetails: details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 没有 nullable,所有 key 都必须存在
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const details = [];
|
||||||
|
if (keyVariants.length > 1) {
|
||||||
|
details.push(`¡ 模板字符串有 ${keyVariants.length} 个可能的变体`);
|
||||||
|
details.push('');
|
||||||
|
}
|
||||||
|
missingKeys.forEach(key => {
|
||||||
|
details.push(` ✘ "${key}" - 未找到`);
|
||||||
|
});
|
||||||
|
foundKeys.forEach(key => {
|
||||||
|
details.push(` ✓ "${key}" - 已找到`);
|
||||||
|
});
|
||||||
|
addDiagnostic(callNode, `i18n key 模板字符串的某些变体未找到: ${missingKeys.join(', ')}`, 9200, {
|
||||||
|
reason: 'MissingTemplateVariants',
|
||||||
|
reasonDetails: details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 检查是否存在(不添加诊断,仅返回结果)
|
||||||
|
*/
|
||||||
|
const checkWithCommonStringExists = (i18nData, key) => {
|
||||||
|
if (commonLocaleKeyRegex.test(key)) {
|
||||||
|
const parts = commonLocaleKeyRegex.exec(key);
|
||||||
|
if (parts && parts.length >= 2) {
|
||||||
|
const namespace = parts[1];
|
||||||
|
const localeData = getCommonLocaleData(namespace);
|
||||||
|
const actualKey = key.substring(namespace.length + 2);
|
||||||
|
return isKeyExistsInI18nData(localeData, actualKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (entityLocaleKeyRegex.test(key)) {
|
||||||
|
const parts = entityLocaleKeyRegex.exec(key);
|
||||||
|
if (parts && parts.length >= 2) {
|
||||||
|
const entity = parts[1];
|
||||||
|
const localeData = getEntityLocaleData(entity);
|
||||||
|
const actualKey = key.substring(entity.length + 1);
|
||||||
|
return isKeyExistsInI18nData(localeData, actualKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
return isKeyExistsInI18nData(i18nData, key);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 检查变量引用的 i18n key
|
||||||
|
*/
|
||||||
|
const checkVariableReferenceKey = (identifier, callNode, i18nData, localePath, typeChecker, addDiagnostic, checkWithCommonString) => {
|
||||||
|
const analysis = analyzeExpressionType(identifier, typeChecker);
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const varName = identifier.getText(sourceFile);
|
||||||
|
if (analysis.isLiteralUnion) {
|
||||||
|
// 字面量联合类型,检查所有可能的值
|
||||||
|
const missingKeys = [];
|
||||||
|
const foundKeys = [];
|
||||||
|
for (const value of analysis.literalValues) {
|
||||||
|
const exists = isKeyExistsInI18nData(i18nData, value) ||
|
||||||
|
checkWithCommonStringExists(i18nData, value);
|
||||||
|
if (exists) {
|
||||||
|
foundKeys.push(value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
missingKeys.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
const details = [];
|
||||||
|
details.push(`¡ 变量 ${varName} 的类型为: ${analysis.literalValues.map(v => `"${v}"`).join(' | ')}`);
|
||||||
|
details.push('');
|
||||||
|
missingKeys.forEach(key => {
|
||||||
|
details.push(` ✘ "${key}" - 未找到`);
|
||||||
|
});
|
||||||
|
foundKeys.forEach(key => {
|
||||||
|
details.push(` ✓ "${key}" - 已找到`);
|
||||||
|
});
|
||||||
|
addDiagnostic(callNode, `变量 ${varName} 的某些可能值在 i18n 中未找到: ${missingKeys.join(', ')}`, 9200, {
|
||||||
|
reason: 'MissingVariableVariants',
|
||||||
|
reasonDetails: details
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (analysis.isNullable) {
|
||||||
|
// 可能为 null/undefined
|
||||||
|
addDiagnostic(callNode, `变量 ${varName} 可能为 null 或 undefined,这可能导致 i18n 查找失败。`, 9204, {
|
||||||
|
reason: 'NullableVariable',
|
||||||
|
reasonDetails: [
|
||||||
|
`✘ 变量 ${varName} 的类型包含 null 或 undefined`,
|
||||||
|
analysis.hasNonNullAssertion
|
||||||
|
? '✓ 使用了非空断言 (!)'
|
||||||
|
: '建议: 添加非空断言或进行空值检查'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (analysis.isString) {
|
||||||
|
// string 类型,无法检查
|
||||||
|
addDiagnostic(callNode, `变量 ${varName} 的类型为 string,范围太大无法检查 i18n key 是否存在。`, 9203, {
|
||||||
|
reason: 'UncheckableVariable',
|
||||||
|
reasonDetails: [
|
||||||
|
`✘ 变量 ${varName} 的类型为 string`,
|
||||||
|
'建议: 使用字面量联合类型限制可能的值,如: "key1" | "key2"'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 其他类型
|
||||||
|
const typeString = typeChecker.typeToString(analysis.originalType);
|
||||||
|
addDiagnostic(callNode, `变量 ${varName} 的类型 (${typeString}) 不是有效的 i18n key 类型。`, 9205, {
|
||||||
|
reason: 'InvalidVariableType',
|
||||||
|
reasonDetails: [
|
||||||
|
`✘ 变量 ${varName} 的类型为: ${typeString}`,
|
||||||
|
'期望: string 字面量或字面量联合类型'
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const checkWithCommonString = (i18nData, key, callNode, localePath) => {
|
||||||
|
if (commonLocaleKeyRegex.test(key)) {
|
||||||
|
// 公共字符串,格式如 common::title, 这里把namespace提取出来
|
||||||
|
const parts = commonLocaleKeyRegex.exec(key);
|
||||||
|
if (parts && parts.length >= 2) {
|
||||||
|
const namespace = parts[1];
|
||||||
|
const localeData = getCommonLocaleData(namespace);
|
||||||
|
const actualKey = key.substring(namespace.length + 2); // 去掉 namespace 和 ::
|
||||||
|
if (isKeyExistsInI18nData(localeData, actualKey)) {
|
||||||
|
return; // 找到,直接返回
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addDiagnostic(callNode, `i18n key "${key}" not found in public locale files: namespace "${namespace}".`, 9200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addDiagnostic(callNode, `i18n key "${key}" has invalid format.`, 9201, {
|
||||||
|
reason: 'InvalidFormat',
|
||||||
|
reasonDetails: [
|
||||||
|
`Expected format: <namespace>::<key>, e.g., common::title`
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (entityLocaleKeyRegex.test(key)) {
|
||||||
|
// 实体字符串,格式如 entity:attr.name
|
||||||
|
const parts = entityLocaleKeyRegex.exec(key);
|
||||||
|
if (parts && parts.length >= 2) {
|
||||||
|
const entity = parts[1];
|
||||||
|
const localeData = getEntityLocaleData(entity);
|
||||||
|
const actualKey = key.substring(entity.length + 1);
|
||||||
|
if (isKeyExistsInI18nData(localeData, actualKey)) {
|
||||||
|
return; // 找到,直接返回
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addDiagnostic(callNode, `i18n key "${key}" not found in entity locale files: entity "${entity}".`, 9200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addDiagnostic(callNode, `i18n key "${key}" has invalid format.`, 9201, {
|
||||||
|
reason: 'InvalidFormat',
|
||||||
|
reasonDetails: [
|
||||||
|
`Expected format: <entity>:<key>, e.g., user:attr.name`
|
||||||
|
]
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 普通字符串,直接检查
|
||||||
|
if (isKeyExistsInI18nData(i18nData, key)) {
|
||||||
|
return; // 找到,直接返回
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
addDiagnostic(callNode, `i18n key "${key}" not found in its locale files: ${localePath}.`, 9200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 逐文件处理
|
||||||
|
groupedCalls.forEach((calls, filePath) => {
|
||||||
|
// 如果这个文件同级目录下没有index.ts, 暂时不做检查
|
||||||
|
const dirPath = path.dirname(path.resolve(pwd, filePath));
|
||||||
|
const localePath = path.join(dirPath, 'locales');
|
||||||
|
const indexTsPath = path.join(dirPath, 'index.ts');
|
||||||
|
if (!fs.existsSync(indexTsPath)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const i18nData = loadI18nData(localePath);
|
||||||
|
if (!i18nData) {
|
||||||
|
// 全部加入警告
|
||||||
|
calls.forEach(callNode => {
|
||||||
|
const args = callNode.arguments;
|
||||||
|
if (args.length === 0) {
|
||||||
|
return; // 没有参数,跳过
|
||||||
|
}
|
||||||
|
const firstArg = args[0];
|
||||||
|
if (ts.isStringLiteral(firstArg)) {
|
||||||
|
const key = firstArg.text;
|
||||||
|
// 检查是否是 entity 或 common 格式
|
||||||
|
if (commonLocaleKeyRegex.test(key) || entityLocaleKeyRegex.test(key)) {
|
||||||
|
// 是 entity/common 格式,使用空对象作为本地 i18n 数据,让 checkWithCommonString 处理
|
||||||
|
checkWithCommonString({}, key, callNode, localePath);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 普通格式的 key,但本地没有 i18n 文件
|
||||||
|
addDiagnostic(callNode, `i18n key "${key}" cannot be checked because no i18n data file found in directory: ${localePath}.`, 9202);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (ts.isTemplateExpression(firstArg) ||
|
||||||
|
ts.isNoSubstitutionTemplateLiteral(firstArg) ||
|
||||||
|
ts.isIdentifier(firstArg) ||
|
||||||
|
ts.isPropertyAccessExpression(firstArg) ||
|
||||||
|
ts.isElementAccessExpression(firstArg)) {
|
||||||
|
// TODO: 对于非字面量的情况,暂时跳过(因为无法确定是否是 entity/common 格式)
|
||||||
|
// 可以考虑添加更详细的类型分析
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 逐调用检查
|
||||||
|
calls.forEach(callNode => {
|
||||||
|
const args = callNode.arguments;
|
||||||
|
if (args.length === 0) {
|
||||||
|
return; // 没有参数,跳过
|
||||||
|
}
|
||||||
|
const firstArg = args[0];
|
||||||
|
if (ts.isStringLiteral(firstArg)) {
|
||||||
|
// 字符串字面量
|
||||||
|
const key = firstArg.text;
|
||||||
|
checkWithCommonString(i18nData, key, callNode, localePath);
|
||||||
|
}
|
||||||
|
else if (ts.isTemplateExpression(firstArg)) {
|
||||||
|
// 模板字符串
|
||||||
|
checkTemplateExpressionKey(firstArg, callNode, i18nData, localePath, typeChecker, addDiagnostic, checkWithCommonString);
|
||||||
|
}
|
||||||
|
else if (ts.isNoSubstitutionTemplateLiteral(firstArg)) {
|
||||||
|
// 无替换的模板字面量 `key`
|
||||||
|
const key = firstArg.text;
|
||||||
|
checkWithCommonString(i18nData, key, callNode, localePath);
|
||||||
|
}
|
||||||
|
else if (ts.isIdentifier(firstArg)) {
|
||||||
|
// 变量引用
|
||||||
|
checkVariableReferenceKey(firstArg, callNode, i18nData, localePath, typeChecker, addDiagnostic, checkWithCommonString);
|
||||||
|
}
|
||||||
|
else if (ts.isPropertyAccessExpression(firstArg) || ts.isElementAccessExpression(firstArg)) {
|
||||||
|
// 属性访问或元素访问,尝试分析类型
|
||||||
|
const analysis = analyzeExpressionType(firstArg, typeChecker);
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const exprText = firstArg.getText(sourceFile);
|
||||||
|
if (analysis.isLiteralUnion) {
|
||||||
|
// 可以检查
|
||||||
|
const missingKeys = [];
|
||||||
|
const foundKeys = [];
|
||||||
|
for (const value of analysis.literalValues) {
|
||||||
|
const exists = isKeyExistsInI18nData(i18nData, value) ||
|
||||||
|
checkWithCommonStringExists(i18nData, value);
|
||||||
|
if (exists) {
|
||||||
|
foundKeys.push(value);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
missingKeys.push(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missingKeys.length > 0) {
|
||||||
|
addDiagnostic(callNode, `表达式 ${exprText} 的某些可能值在 i18n 中未找到: ${missingKeys.join(', ')}`, 9200, {
|
||||||
|
reason: 'MissingExpressionVariants',
|
||||||
|
reasonDetails: [
|
||||||
|
`¡ 表达式 ${exprText} 的类型为: ${analysis.literalValues.map(v => `"${v}"`).join(' | ')}`,
|
||||||
|
'',
|
||||||
|
...missingKeys.map(key => ` ✘ "${key}" - 未找到`),
|
||||||
|
...foundKeys.map(key => ` ✓ "${key}" - 已找到`)
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (analysis.isString) {
|
||||||
|
addDiagnostic(callNode, `表达式 ${exprText} 的类型为 string,范围太大无法检查。`, 9203, {
|
||||||
|
reason: 'UncheckableExpression',
|
||||||
|
reasonDetails: [`✘ 表达式 ${exprText} 的类型为 string`]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
else if (analysis.isNullable) {
|
||||||
|
addDiagnostic(callNode, `表达式 ${exprText} 可能为 null 或 undefined。`, 9204, {
|
||||||
|
reason: 'NullableExpression',
|
||||||
|
reasonDetails: [`✘ 表达式 ${exprText} 可能为 null 或 undefined`]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// 其他复杂表达式
|
||||||
|
const sourceFile = callNode.getSourceFile();
|
||||||
|
const exprText = firstArg.getText(sourceFile);
|
||||||
|
addDiagnostic(callNode, `i18n key 参数使用了复杂表达式 (${exprText}),无法进行检查。`, 9206, {
|
||||||
|
reason: 'ComplexExpression',
|
||||||
|
reasonDetails: [`✘ 表达式 ${exprText} 过于复杂,无法确定其值`]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return diagnostics;
|
||||||
|
};
|
||||||
|
const compile = (pwd, options) => {
|
||||||
// 读取 tsconfig.json
|
// 读取 tsconfig.json
|
||||||
const configFile = ts.readConfigFile(options.project, ts.sys.readFile);
|
const configFile = ts.readConfigFile(options.project, ts.sys.readFile);
|
||||||
|
// 读取自定义配置
|
||||||
|
const customConfig = configFile.config.oakBuildChecks || {};
|
||||||
if (configFile.error) {
|
if (configFile.error) {
|
||||||
console.error(ts.formatDiagnostic(configFile.error, {
|
console.error(ts.formatDiagnostic(configFile.error, {
|
||||||
getCanonicalFileName: (f) => f,
|
getCanonicalFileName: (f) => f,
|
||||||
|
|
@ -1600,12 +2239,12 @@ const compile = (options) => {
|
||||||
// 获取类型检查器
|
// 获取类型检查器
|
||||||
const typeChecker = program.getTypeChecker();
|
const typeChecker = program.getTypeChecker();
|
||||||
// 执行自定义检查(传入自定义配置)
|
// 执行自定义检查(传入自定义配置)
|
||||||
const customDiagnostics = performCustomChecks(program, typeChecker, customConfig);
|
const customDiagnostics = performCustomChecks(pwd, program, typeChecker, customConfig);
|
||||||
if (customDiagnostics.length > 0) {
|
if (customDiagnostics.length > 0) {
|
||||||
// 输出统计
|
// 输出统计
|
||||||
console.log(`${colors.cyan}发现 ${customDiagnostics.length} 个潜在问题:${colors.reset}`);
|
console.log(`${colors.cyan}发现 ${customDiagnostics.length} 个潜在问题:${colors.reset}`);
|
||||||
// 输出详细信息
|
// 输出详细信息
|
||||||
customDiagnostics.forEach(printDiagnostic);
|
customDiagnostics.forEach((diagnostic, index) => printDiagnostic(pwd, diagnostic, index));
|
||||||
// 输出忽略提示
|
// 输出忽略提示
|
||||||
console.log(`\n${colors.yellow}═══════════════════════════════════════════════════════════${colors.reset}`);
|
console.log(`\n${colors.yellow}═══════════════════════════════════════════════════════════${colors.reset}`);
|
||||||
console.log(`${colors.cyan}如果确定逻辑正确,可以使用以下注释标签来忽略检查:${colors.reset}`);
|
console.log(`${colors.cyan}如果确定逻辑正确,可以使用以下注释标签来忽略检查:${colors.reset}`);
|
||||||
|
|
@ -1625,7 +2264,7 @@ const compile = (options) => {
|
||||||
...emitResult.diagnostics,
|
...emitResult.diagnostics,
|
||||||
];
|
];
|
||||||
// 输出诊断信息
|
// 输出诊断信息
|
||||||
allDiagnostics.forEach(printDiagnostic);
|
allDiagnostics.forEach((diagnostic, index) => printDiagnostic(pwd, diagnostic, index));
|
||||||
// 输出编译统计
|
// 输出编译统计
|
||||||
const errorCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Error).length;
|
const errorCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Error).length;
|
||||||
const warningCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Warning).length;
|
const warningCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Warning).length;
|
||||||
|
|
@ -1676,6 +2315,6 @@ const build = (pwd, args) => {
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
options.project = configPath;
|
options.project = configPath;
|
||||||
compile(options);
|
compile(pwd, options);
|
||||||
};
|
};
|
||||||
exports.build = build;
|
exports.build = build;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import * as ts from 'typescript';
|
||||||
|
|
||||||
|
// 缓存结构:WeakMap<Node, Map<modulesKey, boolean>>
|
||||||
|
const isTCallCache = new WeakMap<ts.Node, Map<string, boolean>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 modules 的缓存 key
|
||||||
|
*/
|
||||||
|
function getModulesCacheKey(modules: string[]): string {
|
||||||
|
return modules.sort().join('|');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断节点是否为 t 函数调用
|
||||||
|
* @param node ts.Node
|
||||||
|
* @param typeChecker ts.TypeChecker
|
||||||
|
* @returns boolean
|
||||||
|
* t: (key: string, params?: object | undefined) => string
|
||||||
|
*/
|
||||||
|
export const isTCall = (
|
||||||
|
node: ts.Node,
|
||||||
|
typeChecker: ts.TypeChecker,
|
||||||
|
modules: string[]
|
||||||
|
): boolean => {
|
||||||
|
// 缓存查询
|
||||||
|
const modulesCacheKey = getModulesCacheKey(modules);
|
||||||
|
let nodeCache = isTCallCache.get(node);
|
||||||
|
|
||||||
|
if (nodeCache) {
|
||||||
|
const cachedResult = nodeCache.get(modulesCacheKey);
|
||||||
|
if (cachedResult !== undefined) {
|
||||||
|
return cachedResult;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (!(ts.isCallExpression(node) && ts.isIdentifier(node.expression))) {
|
||||||
|
// 缓存 false 结果
|
||||||
|
if (!nodeCache) {
|
||||||
|
nodeCache = new Map();
|
||||||
|
isTCallCache.set(node, nodeCache);
|
||||||
|
}
|
||||||
|
nodeCache.set(modulesCacheKey, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const result = doIsTCallDirect(node, typeChecker, modules);
|
||||||
|
|
||||||
|
// 缓存结果
|
||||||
|
if (!nodeCache) {
|
||||||
|
nodeCache = new Map();
|
||||||
|
isTCallCache.set(node, nodeCache);
|
||||||
|
}
|
||||||
|
nodeCache.set(modulesCacheKey, result);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
const doIsTCallDirect = (
|
||||||
|
node: ts.Node,
|
||||||
|
typeChecker: ts.TypeChecker,
|
||||||
|
modules: string[]
|
||||||
|
): boolean => {
|
||||||
|
if (!(ts.isCallExpression(node) && ts.isIdentifier(node.expression))) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const symbol = typeChecker.getSymbolAtLocation(node.expression);
|
||||||
|
if (!symbol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations = symbol.getDeclarations();
|
||||||
|
if (!declarations || declarations.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const decl of declarations) {
|
||||||
|
|
||||||
|
const declType = typeChecker.getTypeAtLocation(decl);
|
||||||
|
const signatures = declType.getCallSignatures();
|
||||||
|
|
||||||
|
for (const sig of signatures) {
|
||||||
|
|
||||||
|
// 检查这个类型是否来自指定模块
|
||||||
|
if (!isTypeFromModules(declType, modules)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查返回类型是否为 string
|
||||||
|
const returnType = typeChecker.getReturnTypeOfSignature(sig);
|
||||||
|
if ((returnType.flags & ts.TypeFlags.String) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = sig.getParameters();
|
||||||
|
if (params.length === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查第一个参数是否为 string
|
||||||
|
const firstParamType = typeChecker.getTypeOfSymbolAtLocation(params[0], decl);
|
||||||
|
if ((firstParamType.flags & ts.TypeFlags.String) === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只有一个参数,符合 (key: string) => string
|
||||||
|
if (params.length === 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查第二个参数
|
||||||
|
if (params.length >= 2) {
|
||||||
|
const secondParam = params[1];
|
||||||
|
const secondParamType = typeChecker.getTypeOfSymbolAtLocation(secondParam, decl);
|
||||||
|
|
||||||
|
// 检查第二个参数是否为 object 或 object | undefined
|
||||||
|
if (isObjectOrObjectUnion(secondParamType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查类型是否为 object 或 object | undefined
|
||||||
|
* @param type ts.Type
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function isObjectOrObjectUnion(type: ts.Type): boolean {
|
||||||
|
// 如果是联合类型
|
||||||
|
if (type.isUnion()) {
|
||||||
|
// 检查联合类型中是否包含 object 类型
|
||||||
|
// 允许 object | undefined 的组合
|
||||||
|
let hasObject = false;
|
||||||
|
let hasOnlyObjectAndUndefined = true;
|
||||||
|
|
||||||
|
for (const t of type.types) {
|
||||||
|
if ((t.flags & ts.TypeFlags.Object) !== 0 || (t.flags & ts.TypeFlags.NonPrimitive) !== 0) {
|
||||||
|
hasObject = true;
|
||||||
|
} else if ((t.flags & ts.TypeFlags.Undefined) === 0) {
|
||||||
|
// 如果既不是 object 也不是 undefined,则不符合
|
||||||
|
hasOnlyObjectAndUndefined = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasObject && hasOnlyObjectAndUndefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是单一类型,检查是否为 object
|
||||||
|
return (type.flags & ts.TypeFlags.Object) !== 0 || (type.flags & ts.TypeFlags.NonPrimitive) !== 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查声明是否来自指定的模块
|
||||||
|
* @param decl ts.Declaration
|
||||||
|
* @param modules 模块名称列表
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function isFromModules(decl: ts.Declaration, modules: string[]): boolean {
|
||||||
|
const sourceFile = decl.getSourceFile();
|
||||||
|
if (!sourceFile) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = sourceFile.fileName;
|
||||||
|
|
||||||
|
// 检查文件路径是否包含指定的模块
|
||||||
|
return modules.some(moduleName => {
|
||||||
|
return fileName.includes(moduleName);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查类型是否来自指定模块
|
||||||
|
* @param type ts.Type
|
||||||
|
* @param modules 模块名称列表
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
function isTypeFromModules(type: ts.Type, modules: string[]): boolean {
|
||||||
|
const symbol = type.getSymbol();
|
||||||
|
if (!symbol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarations = symbol.getDeclarations();
|
||||||
|
if (!declarations || declarations.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return declarations.some(decl => isFromModules(decl, modules));
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue