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[];
|
||||
filePatterns?: string[];
|
||||
};
|
||||
locale?: {
|
||||
checkI18nKeys?: boolean;
|
||||
tFunctionModules?: string[];
|
||||
};
|
||||
}
|
||||
interface CustomDiagnostic {
|
||||
file: ts.SourceFile;
|
||||
|
|
@ -36,6 +40,6 @@ export type CompileOptions = {
|
|||
* @param customConfig 自定义配置
|
||||
* @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 {};
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ const ts = tslib_1.__importStar(require("typescript"));
|
|||
const path = tslib_1.__importStar(require("path"));
|
||||
const fs = tslib_1.__importStar(require("fs"));
|
||||
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_TRANSFORM_METHODS = ['map', 'filter', 'flatMap'];
|
||||
const PROMISE_METHODS = ['then', 'catch', 'finally'];
|
||||
const PROMISE_STATIC_METHODS = ['all', 'race', 'allSettled', 'any'];
|
||||
const LOCALE_FILE_NAMES = ['zh_CN.json', 'zh-CN.json'];
|
||||
// 判断是否是函数类声明
|
||||
const isFunctionLikeDeclaration = (node) => {
|
||||
// FunctionDeclaration | MethodDeclaration | GetAccessorDeclaration | SetAccessorDeclaration | ConstructorDeclaration | FunctionExpression | ArrowFunction;
|
||||
|
|
@ -165,9 +168,9 @@ const isIdentifierWithSymbol = (node, symbol, typeChecker) => {
|
|||
* @param customConfig 自定义配置
|
||||
* @returns boolean
|
||||
*/
|
||||
const isFileInCheckScope = (fileName, customConfig) => {
|
||||
const isFileInCheckScope = (pwd, fileName, customConfig) => {
|
||||
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) {
|
||||
if ((0, glob_1.matchGlobPattern)(normalizedFileName, pattern.replace(/\\/g, '/'))) {
|
||||
return true;
|
||||
|
|
@ -216,24 +219,14 @@ function parseArgs(pargs) {
|
|||
}
|
||||
return options;
|
||||
}
|
||||
// 读取自定义配置
|
||||
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 getContextLocationText = (pwd, callNode) => {
|
||||
const sourceFile = callNode.getSourceFile();
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(callNode.getStart());
|
||||
// const callText = callNode.getText(sourceFile);
|
||||
// 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;
|
||||
if (diagnostic.file && diagnostic.start !== undefined) {
|
||||
const { line, character } = diagnostic.file.getLineAndCharacterOfPosition(diagnostic.start);
|
||||
|
|
@ -243,7 +236,7 @@ function printDiagnostic(diagnostic, index) {
|
|||
const categoryColor = isError ? colors.red : colors.yellow;
|
||||
// 主要错误信息
|
||||
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}`);
|
||||
// 显示代码片段
|
||||
const sourceFile = diagnostic.file;
|
||||
|
|
@ -284,7 +277,7 @@ function printDiagnostic(diagnostic, index) {
|
|||
// 显示实际的context调用位置
|
||||
if (customDiag.contextCallNode) {
|
||||
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) {
|
||||
|
|
@ -327,13 +320,12 @@ function printDiagnostic(diagnostic, index) {
|
|||
* @param customConfig 自定义配置
|
||||
* @returns CustomDiagnostic[] 自定义诊断列表
|
||||
*/
|
||||
function performCustomChecks(program, typeChecker, customConfig) {
|
||||
function performCustomChecks(pwd, program, typeChecker, customConfig) {
|
||||
const diagnostics = [];
|
||||
// 如果自定义检查被禁用,直接返回
|
||||
if (!customConfig.context?.checkAsyncContext) {
|
||||
console.log('Custom AsyncContext checks are disabled.');
|
||||
return diagnostics;
|
||||
}
|
||||
const enableAsyncContextCheck = customConfig.context?.checkAsyncContext !== false;
|
||||
const enableTCheck = customConfig.locale?.checkI18nKeys !== false;
|
||||
console.log(`Custom AsyncContext checks are ${enableAsyncContextCheck ? colors.green + 'enabled' + colors.reset : colors.gray + 'disabled' + colors.reset}.`);
|
||||
console.log(`Custom i18n key checks are ${enableTCheck ? colors.green + 'enabled' + colors.reset : colors.gray + 'disabled' + colors.reset}.`);
|
||||
// 数据结构定义
|
||||
const functionsWithContextCalls = new Set();
|
||||
const functionDeclarations = new Map();
|
||||
|
|
@ -353,6 +345,8 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
|||
const ignoreCommentNodes = new Set();
|
||||
// 符号缓存
|
||||
const symbolCache = new Map();
|
||||
// t函数调用符号缓存
|
||||
const tFunctionSymbolCache = new Set();
|
||||
const getSymbolCached = (node) => {
|
||||
if (symbolCache.has(node)) {
|
||||
return symbolCache.get(node);
|
||||
|
|
@ -780,40 +774,45 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
|||
}
|
||||
// 处理调用表达式 - 合并原来的两个逻辑
|
||||
if (ts.isCallExpression(node)) {
|
||||
// 检查 context 调用
|
||||
if (ts.isPropertyAccessExpression(node.expression)) {
|
||||
const objectType = typeChecker.getTypeAtLocation(node.expression.expression);
|
||||
const targetModules = customConfig.context?.targetModules ||
|
||||
['@project/context/BackendRuntimeContext'];
|
||||
if (isAsyncContextType(objectType, targetModules)) {
|
||||
if (!hasIgnoreComment(node, sourceFile)) {
|
||||
allContextCalls.push(node);
|
||||
if (currentFunction) {
|
||||
if (!directContextCalls.has(currentFunction)) {
|
||||
directContextCalls.set(currentFunction, []);
|
||||
if (enableAsyncContextCheck) {
|
||||
// 检查 context 调用
|
||||
if (ts.isPropertyAccessExpression(node.expression)) {
|
||||
const objectType = typeChecker.getTypeAtLocation(node.expression.expression);
|
||||
const targetModules = customConfig.context?.targetModules ||
|
||||
['@project/context/BackendRuntimeContext'];
|
||||
if (isAsyncContextType(objectType, targetModules)) {
|
||||
if (!hasIgnoreComment(node, sourceFile)) {
|
||||
allContextCalls.push(node);
|
||||
if (currentFunction) {
|
||||
if (!directContextCalls.has(currentFunction)) {
|
||||
directContextCalls.set(currentFunction, []);
|
||||
}
|
||||
directContextCalls.get(currentFunction).push(node);
|
||||
}
|
||||
directContextCalls.get(currentFunction).push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 记录函数调用关系 + 收集需要检查的调用
|
||||
if (currentFunction) {
|
||||
const signature = typeChecker.getResolvedSignature(node);
|
||||
if (signature) {
|
||||
const declaration = signature.getDeclaration();
|
||||
if (declaration) {
|
||||
const calledSymbol = getSymbolOfDeclaration(declaration);
|
||||
if (calledSymbol && calledSymbol !== currentFunction) {
|
||||
if (!functionCallGraph.has(currentFunction)) {
|
||||
functionCallGraph.set(currentFunction, new Set());
|
||||
}
|
||||
functionCallGraph.get(currentFunction).add(calledSymbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 收集可能需要检查的调用(延迟到标记传播后)
|
||||
localCallsToCheck.push(node);
|
||||
}
|
||||
}
|
||||
// 记录函数调用关系 + 收集需要检查的调用
|
||||
if (currentFunction) {
|
||||
const signature = typeChecker.getResolvedSignature(node);
|
||||
if (signature) {
|
||||
const declaration = signature.getDeclaration();
|
||||
if (declaration) {
|
||||
const calledSymbol = getSymbolOfDeclaration(declaration);
|
||||
if (calledSymbol && calledSymbol !== currentFunction) {
|
||||
if (!functionCallGraph.has(currentFunction)) {
|
||||
functionCallGraph.set(currentFunction, new Set());
|
||||
}
|
||||
functionCallGraph.get(currentFunction).add(calledSymbol);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 收集可能需要检查的调用(延迟到标记传播后)
|
||||
localCallsToCheck.push(node);
|
||||
if (enableTCheck && (0, identifier_1.isTCall)(node, typeChecker, customConfig.locale?.tFunctionModules || ['oak-frontend-base'])) {
|
||||
tFunctionSymbolCache.add(node);
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
|
|
@ -1536,7 +1535,7 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
|||
continue;
|
||||
}
|
||||
// 文件名是否符合检查范围
|
||||
if (!isFileInCheckScope(sourceFile.fileName, customConfig)) {
|
||||
if (!isFileInCheckScope(pwd, sourceFile.fileName, customConfig)) {
|
||||
continue;
|
||||
}
|
||||
preprocessIgnoreComments(sourceFile); // 预处理注释
|
||||
|
|
@ -1552,13 +1551,653 @@ function performCustomChecks(program, typeChecker, customConfig) {
|
|||
propagateContextMarks();
|
||||
checkDirectContextCalls(); // 检查直接调用
|
||||
checkIndirectCalls(); // 检查间接调用(使用缓存的调用列表)
|
||||
return diagnostics;
|
||||
const i18nDiagnostics = checkI18nKeys(pwd, tFunctionSymbolCache, program, typeChecker);
|
||||
return diagnostics.concat(i18nDiagnostics);
|
||||
}
|
||||
const compile = (options) => {
|
||||
// 读取自定义配置
|
||||
const customConfig = readCustomConfig(options.project);
|
||||
const loadI18nData = (dirPath) => {
|
||||
// 尝试加载 LOCALE_FILE_NAMES 中的文件
|
||||
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
|
||||
const configFile = ts.readConfigFile(options.project, ts.sys.readFile);
|
||||
// 读取自定义配置
|
||||
const customConfig = configFile.config.oakBuildChecks || {};
|
||||
if (configFile.error) {
|
||||
console.error(ts.formatDiagnostic(configFile.error, {
|
||||
getCanonicalFileName: (f) => f,
|
||||
|
|
@ -1600,12 +2239,12 @@ const compile = (options) => {
|
|||
// 获取类型检查器
|
||||
const typeChecker = program.getTypeChecker();
|
||||
// 执行自定义检查(传入自定义配置)
|
||||
const customDiagnostics = performCustomChecks(program, typeChecker, customConfig);
|
||||
const customDiagnostics = performCustomChecks(pwd, program, typeChecker, customConfig);
|
||||
if (customDiagnostics.length > 0) {
|
||||
// 输出统计
|
||||
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(`${colors.cyan}如果确定逻辑正确,可以使用以下注释标签来忽略检查:${colors.reset}`);
|
||||
|
|
@ -1625,7 +2264,7 @@ const compile = (options) => {
|
|||
...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 warningCount = allDiagnostics.filter(d => d.category === ts.DiagnosticCategory.Warning).length;
|
||||
|
|
@ -1676,6 +2315,6 @@ const build = (pwd, args) => {
|
|||
process.exit(1);
|
||||
}
|
||||
options.project = configPath;
|
||||
compile(options);
|
||||
compile(pwd, options);
|
||||
};
|
||||
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