feat: 支持i18n 相关检查

This commit is contained in:
Pan Qiancheng 2026-01-06 16:19:41 +08:00
parent 99e43fce22
commit bafe3e0c36
6 changed files with 1952 additions and 128 deletions

9
lib/compiler/identifier.d.ts vendored Normal file
View File

@ -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;

160
lib/compiler/identifier.js Normal file
View File

@ -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));
}

View File

@ -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 {};

View File

@ -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;

196
src/compiler/identifier.ts Normal file
View File

@ -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