Compare commits

...

2 Commits

5 changed files with 485 additions and 76 deletions

View File

@ -173,27 +173,30 @@ function pushStatementIntoSchemaAst(moduleName, statement, sourceFile) {
*/
function checkActionDefNameConsistent(filename, actionDefNode) {
const { name, type } = actionDefNode;
(0, assert_1.default)(ts.isTypeReferenceNode(type), "ActionDef应该是一个类型引用");
(0, assert_1.default)(ts.isTypeReferenceNode(type), `${filename}」中的 ActionDef 定义错误:必须是类型引用,如 ActionDef<CreateAction, CreateState>`);
const { typeArguments } = type;
(0, assert_1.default)(typeArguments.length === 2);
(0, assert_1.default)(typeArguments.length === 2, `${filename}」中的 ActionDef 定义错误:必须有两个类型参数 <Action, State>`);
const [actionNode, stateNode] = typeArguments;
(0, assert_1.default)(ts.isIdentifier(name), `文件${filename}中的ActionDef${name.text}不是一个有效的变量`);
(0, assert_1.default)(name.text.endsWith('ActionDef'), `文件${filename}中的ActionDef${name.text}未以ActionDef结尾`);
(0, assert_1.default)(ts.isTypeReferenceNode(actionNode) && ts.isTypeReferenceNode(stateNode), `文件${filename}中的ActionDef${name.text}类型声明中的action和state非法`);
(0, assert_1.default)(ts.isIdentifier(actionNode.typeName) && ts.isIdentifier(stateNode.typeName));
(0, assert_1.default)(actionNode.typeName.text.endsWith('Action'), `文件${filename}中的ActionDef${name.text}所引用的Action${actionNode.typeName}未以Action结尾`);
(0, assert_1.default)(stateNode.typeName.text.endsWith('State'), `文件${filename}中的ActionDef${name.text}所引用的State${stateNode.typeName}未以State结尾`);
(0, assert_1.default)(ts.isIdentifier(name), `${filename}」中的 ActionDef「${name.text}不是有效的变量`);
(0, assert_1.default)(name.text.endsWith('ActionDef'), `${filename}」中的变量「${name.text}」命名错误ActionDef 变量必须以 'ActionDef' 结尾`);
(0, assert_1.default)(ts.isTypeReferenceNode(actionNode) && ts.isTypeReferenceNode(stateNode), `${filename}」中的「${name.text}」类型参数错误Action 和 State 必须是类型引用`);
(0, assert_1.default)(ts.isIdentifier(actionNode.typeName) && ts.isIdentifier(stateNode.typeName), `${filename}」中的「${name.text}」类型参数错误Action 和 State 必须是简单标识符`);
(0, assert_1.default)(actionNode.typeName.text.endsWith('Action'), `${filename}」中的「${name.text}」引用的 Action 类型「${actionNode.typeName.text}」命名错误:必须以 'Action' 结尾`);
(0, assert_1.default)(stateNode.typeName.text.endsWith('State'), `${filename}」中的「${name.text}」引用的 State 类型「${stateNode.typeName.text}」命名错误:必须以 'State' 结尾`);
const adfName = name.text.slice(0, name.text.length - 9);
const aName = actionNode.typeName.text.slice(0, actionNode.typeName.text.length - 6);
const sName = stateNode.typeName.text.slice(0, stateNode.typeName.text.length - 5);
(0, assert_1.default)(adfName === aName && aName === sName, `文件${filename}中的ActionDef${name.text}中ActionDef, Action和State的命名规则不一致 需要
${adfName}ActionDef, ${adfName}Action, ${adfName}State`);
(0, assert_1.default)(adfName === aName && aName === sName, `${filename}」中的「${name.text}」命名不一致。\n` +
`要求ActionDef、Action、State 的前缀必须相同\n` +
`当前:${name.text}, ${actionNode.typeName.text}, ${stateNode.typeName.text}\n` +
`应为:${adfName}ActionDef, ${adfName}Action, ${adfName}State`);
}
function checkStringLiteralLegal(filename, obj, text, ele) {
(0, assert_1.default)(ts.isLiteralTypeNode(ele) && ts.isStringLiteral(ele.literal), `${filename}中引用的${obj} ${text}中存在不是stringliteral的类型`);
(0, assert_1.default)(!ele.literal.text.includes('$'), `${filename}中引用的action${text}中的${obj}${ele.literal.text}」包含非法字符$`);
(0, assert_1.default)(ele.literal.text.length > 0, `${filename}中引用的action${text}中的${obj}${ele.literal.text}」长度非法`);
(0, assert_1.default)(ele.literal.text.length < env_1.STRING_LITERAL_MAX_LENGTH, `${filename}中引用的${obj} ${text}中的「${ele.literal.text}」长度过长`);
(0, assert_1.default)(ts.isLiteralTypeNode(ele) && ts.isStringLiteral(ele.literal), `${filename}」中的 ${obj}${text}」类型错误:必须是字符串字面量类型,如 'create' | 'update'`);
(0, assert_1.default)(!ele.literal.text.includes('$'), `${filename}」中的 ${obj}${text}」包含非法字符:「${ele.literal.text}」中不能包含 '$' 字符`);
(0, assert_1.default)(ele.literal.text.length > 0, `${filename}」中的 ${obj}${text}」不能为空字符串`);
(0, assert_1.default)(ele.literal.text.length < env_1.STRING_LITERAL_MAX_LENGTH, `${filename}」中的 ${obj}${text}」的值「${ele.literal.text}」长度超限。\n` +
`当前长度:${ele.literal.text.length},最大允许:${env_1.STRING_LITERAL_MAX_LENGTH}`);
return ele.literal.text;
}
function addImportedFrom(moduleName, name, node) {
@ -395,12 +398,14 @@ function dealWithActionTypeNode(moduleName, filename, actionTypeNode, program, s
return ele.typeName.text;
}
}).filter(ele => !!ele);
(0, assert_1.default)((0, lodash_1.intersection)(actionNames, RESERVED_ACTION_NAMES).length === 0, `${filename}中的Action命名不能是「${RESERVED_ACTION_NAMES.join(',')}」之一`);
(0, assert_1.default)((0, lodash_1.intersection)(actionNames, RESERVED_ACTION_NAMES).length === 0, `${filename}」中的 Action 命名冲突:不能使用保留名称。\n` +
`保留名称:${RESERVED_ACTION_NAMES.join(', ')}\n` +
`冲突的名称:${(0, lodash_1.intersection)(actionNames, RESERVED_ACTION_NAMES).join(', ')}`);
actionTypeNode.types.forEach(ele => {
if (ts.isTypeReferenceNode(ele)) {
// 这里递归了类型定义去查找所有的action字符串
const actionStrings = tryGetStringLiteralValues(moduleName, filename, 'action', ele, program);
(0, assert_1.default)(actionStrings.length > 0, `${filename}中的Action所引用的Type定义为空`);
(0, assert_1.default)(actionStrings.length > 0, `${filename}」中的 Action 类型「${ele.typeName.text}」定义为空,必须至少包含一个 action`);
actionTexts.push(...actionStrings);
}
else {
@ -411,7 +416,9 @@ function dealWithActionTypeNode(moduleName, filename, actionTypeNode, program, s
}
else if (ts.isTypeReferenceNode(actionTypeNode)) {
if (ts.isIdentifier(actionTypeNode.typeName)) {
(0, assert_1.default)(!RESERVED_ACTION_NAMES.includes(actionTypeNode.typeName.text), `${filename}中的Action命名不能是「${RESERVED_ACTION_NAMES.join(',')}」之一`);
(0, assert_1.default)((0, lodash_1.intersection)([actionTypeNode.typeName.text], RESERVED_ACTION_NAMES).length === 0, `${filename}」中的 Action 命名冲突:不能使用保留名称。\n` +
`保留名称:${RESERVED_ACTION_NAMES.join(', ')}\n` +
`冲突的名称:${(0, lodash_1.intersection)([actionTypeNode.typeName.text], RESERVED_ACTION_NAMES).join(', ')}`);
}
const actionStrings = tryGetStringLiteralValues(moduleName, filename, 'action', actionTypeNode, program);
(0, assert_1.default)(actionStrings.length > 0, `${filename}中的Action定义为空`);
@ -424,8 +431,9 @@ function dealWithActionTypeNode(moduleName, filename, actionTypeNode, program, s
// 所有的action定义不能有重名
const ActionDict = {};
actionTexts.forEach((action) => {
(0, assert_1.default)(action.length <= env_1.STRING_LITERAL_MAX_LENGTH, `${filename}中的Action「${action}」命名长度大于${env_1.STRING_LITERAL_MAX_LENGTH}`);
(0, assert_1.default)(/^[a-z][a-z|A-Z]+$/.test(action), `${filename}中的Action「${action}」命名不合法,必须以小字字母开头且只能包含字母`);
(0, assert_1.default)(action.length <= env_1.STRING_LITERAL_MAX_LENGTH, `${filename}」中的 Action「${action}」命名长度超限。\n` +
`当前长度:${action.length},最大允许:${env_1.STRING_LITERAL_MAX_LENGTH}`);
(0, assert_1.default)(/^[a-z][a-zA-Z]+$/.test(action), `${filename}」中的 Action「${action}」命名格式错误:必须以小写字母开头且只能包含字母`);
if (ActionDict.hasOwnProperty(action)) {
throw new Error(`文件${filename}Action定义上的【${action}】动作存在同名`);
}
@ -644,15 +652,16 @@ function checkLocaleExpressionPropertyExists(root, attr, exists, filename) {
}
} */
function checkNameLegal(filename, attrName, upperCase) {
(0, assert_1.default)(attrName.length <= env_1.ENTITY_NAME_MAX_LENGTH, `文件「${filename}」:「${attrName}」的名称定义过长,不能超过「${env_1.ENTITY_NAME_MAX_LENGTH}」长度`);
(0, assert_1.default)(attrName.length <= env_1.ENTITY_NAME_MAX_LENGTH, `${filename}」中的名称「${attrName}」长度超限。\n` +
`当前长度:${attrName.length},最大允许:${env_1.ENTITY_NAME_MAX_LENGTH}`);
if (upperCase) {
(0, assert_1.default)(/[A-Z][a-z|A-Z|0-9]+/i.test(attrName), `文件「${filename}」:「${attrName}」的名称必须以大写字母开始,且只能包含字母和数字`);
(0, assert_1.default)(/[A-Z][a-z|A-Z|0-9]+/i.test(attrName), `${filename}」中的名称「${attrName}」格式错误必须以大写字母开头只能包含字母和数字UserProfile`);
}
else if (upperCase === false) {
(0, assert_1.default)(/[a-z][a-z|A-Z|0-9]+/i.test(attrName), `文件「${filename}」:「${attrName}」的名称必须以小写字母开始,且只能包含字母和数字`);
(0, assert_1.default)(/[a-z][a-z|A-Z|0-9]+/i.test(attrName), `${filename}」中的名称「${attrName}」格式错误必须以小写字母开头只能包含字母和数字userName`);
}
else {
(0, assert_1.default)(/[a-z|A-Z][a-z|A-Z|0-9]+/i.test(attrName), `文件「${filename}」:「${attrName}」的名称必须以字母开始,且只能包含字母和数字`);
(0, assert_1.default)(/[a-z|A-Z][a-z|A-Z|0-9]+/i.test(attrName), `${filename}」中的名称「${attrName}」格式错误:必须以字母开头,只能包含字母和数字`);
}
}
/**
@ -838,7 +847,8 @@ function analyzeSchemaDefinition(node, moduleName, filename, path, program, refe
let toLog = false;
const extendsFrom = [];
const { members, heritageClauses } = node;
(0, assert_1.default)(heritageClauses, `${filename}」中的Schema定义需要继承EntityShape或者其他Entity`);
(0, assert_1.default)(heritageClauses, `${filename}」中的 Schema 定义错误:必须继承 EntityShape 或其他实体。\n` +
`示例export interface Schema extends EntityShape { ... }`);
const heritagedResult = heritageClauses.map((clause) => {
const { expression } = clause.types[0];
(0, assert_1.default)(ts.isIdentifier(expression), `${expression}不是一个合法的继承类型`);
@ -847,7 +857,8 @@ function analyzeSchemaDefinition(node, moduleName, filename, path, program, refe
return;
}
// 从其它文件的Schema类继承这里需要将所继承的对象的属性进行访问并展开
(0, assert_1.default)(referencedSchemas.includes(expression.text), `${filename}」中的Schema继承了未定义的实体「${expression.text}`);
(0, assert_1.default)(referencedSchemas.includes(expression.text), `${filename}」中的 Schema 继承了未导入的实体「${expression.text}」。\n` +
`提示请先导入该实体import { Schema as ${expression.text} } from '...'`);
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(expression);
let declaration = symbol?.getDeclarations()[0];
@ -879,11 +890,19 @@ function analyzeSchemaDefinition(node, moduleName, filename, path, program, refe
else if (type.typeName.text === 'Array') {
// 这是一对多的反向指针的引用,需要特殊处理
const { typeArguments } = type;
(0, assert_1.default)(typeArguments.length === 1
&& ts.isTypeReferenceNode(typeArguments[0])
&& ts.isIdentifier(typeArguments[0].typeName)
&& referencedSchemas.includes(typeArguments[0].typeName.text), `${filename}」非法的属性定义「${attrName}`);
const reverseEntity = typeArguments[0].typeName.text;
(0, assert_1.default)(typeArguments && typeArguments.length === 1, `${filename}」的属性「${attrName}Array 类型必须有且仅有一个类型参数`);
const elementType = typeArguments[0];
(0, assert_1.default)(ts.isTypeReferenceNode(elementType), `${filename}」的属性「${attrName}Array<${elementType.getText()}> 的元素类型必须是实体引用,用于记录反向指针关系。\n` +
`提示:\n` +
` - 如需存储字符串数组,请使用 string[] 类型或者自定义类型存储 JSONtags?: string[]\n` +
` - 如需关联实体请使用实体类型attachments?: Array<ExtraFile>`);
(0, assert_1.default)(ts.isIdentifier(elementType.typeName), `${filename}」的属性「${attrName}Array 的元素类型必须是简单标识符`);
const elementTypeName = elementType.typeName.text;
(0, assert_1.default)(referencedSchemas.includes(elementTypeName), `${filename}」的属性「${attrName}Array<${elementTypeName}> 中的 ${elementTypeName} 不是有效的实体引用。\n` +
`提示:\n` +
` - 请确保已导入该实体import { Schema as ${elementTypeName} } from '...';\n` +
` - 或改用 Object 类型或者自定义类型存储 JSON 数据`);
const reverseEntity = elementTypeName;
if (ReversePointerRelations[reverseEntity]) {
if (!ReversePointerRelations[reverseEntity].includes(moduleName)) {
ReversePointerRelations[reverseEntity].push(moduleName);
@ -1042,7 +1061,8 @@ function analyzeReferenceSchemaFile(moduleName, filename, path, sourceFile, prog
}
}
});
(0, assert_1.default)(result, `${filename}」中没有找到Schema定义`);
(0, assert_1.default)(result, `${filename}」中缺少 Schema 定义。\n` +
`提示:每个实体文件必须包含 'export interface Schema extends EntityShape { ... }'`);
return result;
}
function analyzeEntity(filename, path, program, relativePath) {
@ -1913,7 +1933,8 @@ function constructFilter(statements, entity) {
}
else {
// 此时应当是引用本地定义的shape
(0, assert_1.default)(type, `${entity}中的属性${name.toString()}有非法的属性类型定义`);
(0, assert_1.default)(type, `${entity}」的属性「${name.toString()}」缺少类型定义。\n` +
`提示每个属性都必须明确指定类型name: String<32>`);
members.push(factory.createPropertySignature(undefined, name, undefined, factory.createTypeReferenceNode(factory.createIdentifier('JsonFilter'), [
type
])));
@ -4174,11 +4195,12 @@ function constructAttributes(entity) {
if (ts.isUnionTypeNode(type)) {
if (ts.isLiteralTypeNode(type.types[0])) {
if (ts.isStringLiteral(type.types[0].literal)) {
(0, assert_1.default)(enumAttributes && enumAttributes[name.text], `${entity}对象中的${name.text}属性没有定义enumAttributes`);
(0, assert_1.default)(enumAttributes && enumAttributes[name.text], `${entity}」的属性「${name.text}」缺少枚举值定义。\n` +
`提示:枚举类型属性需要在 enumAttributes 中定义可选值`);
attrAssignments.push(factory.createPropertyAssignment('type', factory.createStringLiteral("enum")), factory.createPropertyAssignment('enumeration', factory.createArrayLiteralExpression(enumAttributes[name.text].map(ele => factory.createStringLiteral(ele)))));
}
else {
(0, assert_1.default)(ts.isNumericLiteral(type.types[0].literal), `${entity}对象中的${type.types[0].literal.getText()}属性不是数字`);
(0, assert_1.default)(ts.isNumericLiteral(type.types[0].literal), `${entity}」的属性类型错误:「${type.types[0].literal.getText()}」不是有效的数字字面量`);
attrAssignments.push(factory.createPropertyAssignment(factory.createIdentifier("type"), factory.createStringLiteral("int")), factory.createPropertyAssignment(factory.createIdentifier("params"), factory.createObjectLiteralExpression([
factory.createPropertyAssignment(factory.createIdentifier("width"), factory.createNumericLiteral(env_1.INT_LITERL_DEFAULT_WIDTH))
], true)));
@ -4195,7 +4217,7 @@ function constructAttributes(entity) {
attrAssignments.push(factory.createPropertyAssignment(factory.createIdentifier("type"), factory.createStringLiteral("varchar")), factory.createPropertyAssignment(factory.createIdentifier("params"), factory.createObjectLiteralExpression([factory.createPropertyAssignment(factory.createIdentifier("length"), factory.createNumericLiteral(env_1.STRING_LITERAL_MAX_LENGTH))], true)));
}
else {
(0, assert_1.default)(ts.isNumericLiteral(type.literal), `${entity}对象中的${name.text}属性不是数字`);
(0, assert_1.default)(ts.isNumericLiteral(type.literal), `${entity}」的属性「${name.text}」类型错误:必须是数字字面量类型`);
attrAssignments.push(factory.createPropertyAssignment(factory.createIdentifier("type"), factory.createStringLiteral("precision")), factory.createPropertyAssignment(factory.createIdentifier("params"), factory.createObjectLiteralExpression([
factory.createPropertyAssignment(factory.createIdentifier("precision"), factory.createNumericLiteral(env_1.NUMERICAL_LITERL_DEFAULT_PRECISION)),
factory.createPropertyAssignment(factory.createIdentifier("scale"), factory.createNumericLiteral(env_1.NUMERICAL_LITERL_DEFAULT_SCALE))

View File

@ -11,6 +11,8 @@ interface OakBuildChecksConfig {
tFunctionModules?: string[];
checkTemplateLiterals?: boolean;
warnStringKeys?: boolean;
checkJsxLiterals?: boolean;
jsxLiteralPattern?: string;
};
}
interface CustomDiagnostic {

View File

@ -2549,6 +2549,129 @@ function performCustomChecks(pwd, program, typeChecker, customConfig) {
});
return diagnostics;
};
const checkJsxLiterals = (pwd, program, typeChecker, customConfig) => {
const diagnostics = [];
// 默认匹配中文字符的正则
const defaultPattern = /[\u4e00-\u9fa5]/;
const patternStr = customConfig.locale?.jsxLiteralPattern;
const pattern = patternStr ? new RegExp(patternStr) : defaultPattern;
const addDiagnostic = (node, text, messageText, code) => {
const sourceFile = node.getSourceFile();
diagnostics.push({
file: sourceFile,
start: node.getStart(sourceFile),
length: node.getWidth(sourceFile),
messageText,
category: ts.DiagnosticCategory.Warning,
code,
reason: 'HardcodedJsxText',
reasonDetails: [
`✘ 发现硬编码文本: "${text}"`,
'建议: 使用 t() 函数进行国际化处理'
]
});
};
// 检查文本是否需要国际化
const needsI18n = (text) => {
// 移除前后空白
const trimmed = text.trim();
// 忽略空字符串
if (trimmed.length === 0)
return false;
// 忽略纯数字
if (/^\d+$/.test(trimmed))
return false;
// 忽略纯标点符号和特殊字符
if (/^[^\w\u4e00-\u9fa5]+$/.test(trimmed))
return false;
// 匹配需要国际化的模式
return pattern.test(trimmed);
};
// 遍历所有源文件
for (const sourceFile of program.getSourceFiles()) {
// 跳过声明文件
if (sourceFile.isDeclarationFile)
continue;
// 只检查 tsx 文件
if (!sourceFile.fileName.endsWith('.tsx'))
continue;
// 检查是否在检查范围内
if (!isFileInCheckScope(pwd, sourceFile.fileName, customConfig))
continue;
const visit = (node) => {
// 检查 JsxText 节点
if (ts.isJsxText(node)) {
// 检查是否有忽略注释
if (hasIgnoreComment(node, sourceFile)) {
return;
}
const text = node.getText(sourceFile);
if (needsI18n(text)) {
addDiagnostic(node, text.trim(), `JSX 文本包含需要国际化的内容: "${text.trim()}"`, 9300);
}
}
// 检查 JsxAttribute 节点
if (ts.isJsxAttribute(node)) {
// 检查是否有忽略注释
if (hasIgnoreComment(node, sourceFile)) {
return;
}
const initializer = node.initializer;
if (!initializer) {
ts.forEachChild(node, visit);
return;
}
// 情况1: 直接字符串字面量 desc="text"
if (ts.isStringLiteral(initializer)) {
const text = initializer.text;
if (needsI18n(text)) {
addDiagnostic(initializer, text, `JSX 属性包含需要国际化的内容: ${node.name.getText()}="${text}"`, 9301);
}
}
// 情况2: JSX 表达式 desc={'text'} 或 desc={`text`}
else if (ts.isJsxExpression(initializer)) {
const expression = initializer.expression;
if (!expression) {
ts.forEachChild(node, visit);
return;
}
// 检查字符串字面量
if (ts.isStringLiteral(expression)) {
const text = expression.text;
if (needsI18n(text)) {
addDiagnostic(expression, text, `JSX 属性包含需要国际化的内容: ${node.name.getText()}={'${text}'}`, 9301);
}
}
// 检查模板字面量(无替换)
else if (ts.isNoSubstitutionTemplateLiteral(expression)) {
const text = expression.text;
if (needsI18n(text)) {
addDiagnostic(expression, text, `JSX 属性包含需要国际化的内容: ${node.name.getText()}={\`${text}\`}`, 9301);
}
}
// 检查模板表达式(有替换) - 可选的更严格检查
else if (ts.isTemplateExpression(expression)) {
// 检查模板头部
const headText = expression.head.text;
if (needsI18n(headText)) {
addDiagnostic(expression.head, headText, `JSX 属性的模板字符串包含需要国际化的内容: ${node.name.getText()}`, 9302);
}
// 检查每个模板片段
expression.templateSpans.forEach(span => {
const spanText = span.literal.text;
if (needsI18n(spanText)) {
addDiagnostic(span.literal, spanText, `JSX 属性的模板字符串包含需要国际化的内容: ${node.name.getText()}`, 9302);
}
});
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
return diagnostics;
};
// 信息收集
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) {
@ -2572,7 +2695,16 @@ function performCustomChecks(pwd, program, typeChecker, customConfig) {
checkDirectContextCalls(); // 检查直接调用
checkIndirectCalls(); // 检查间接调用(使用缓存的调用列表)
const i18nDiagnostics = checkI18nKeys(pwd, tFunctionSymbolCache, program, typeChecker);
return diagnostics.concat(i18nDiagnostics);
let jsxLiteralDiagnostics = [];
const enableJsxLiteralCheck = customConfig.locale?.checkJsxLiterals === true;
if (enableJsxLiteralCheck) {
console.log(`Custom JSX literal checks are ${colors.green + 'enabled' + colors.reset}.`);
jsxLiteralDiagnostics = checkJsxLiterals(pwd, program, typeChecker, customConfig);
}
else {
console.log(`Custom JSX literal checks are ${colors.gray + 'disabled' + colors.reset}.`);
}
return diagnostics.concat(i18nDiagnostics).concat(jsxLiteralDiagnostics);
}
const loadI18nData = (dirPath) => {
// 尝试加载 LOCALE_FILE_NAMES 中的文件

View File

@ -281,31 +281,47 @@ function pushStatementIntoSchemaAst(moduleName: string, statement: ts.Statement,
*/
function checkActionDefNameConsistent(filename: string, actionDefNode: ts.VariableDeclaration) {
const { name, type } = actionDefNode;
assert(ts.isTypeReferenceNode(type!), "ActionDef应该是一个类型引用");
assert(ts.isTypeReferenceNode(type!),
`${filename}」中的 ActionDef 定义错误:必须是类型引用,如 ActionDef<CreateAction, CreateState>`);
const { typeArguments } = type!;
assert(typeArguments!.length === 2);
assert(typeArguments!.length === 2,
`${filename}」中的 ActionDef 定义错误:必须有两个类型参数 <Action, State>`);
const [actionNode, stateNode] = typeArguments!;
assert(ts.isIdentifier(name), `文件${filename}中的ActionDef${(<ts.Identifier>name).text}不是一个有效的变量`);
assert(name.text.endsWith('ActionDef'), `文件${filename}中的ActionDef${name.text}未以ActionDef结尾`);
assert(ts.isTypeReferenceNode(actionNode) && ts.isTypeReferenceNode(stateNode), `文件${filename}中的ActionDef${name.text}类型声明中的action和state非法`);
assert(ts.isIdentifier(actionNode.typeName) && ts.isIdentifier(stateNode.typeName));
assert(actionNode.typeName.text.endsWith('Action'), `文件${filename}中的ActionDef${name.text}所引用的Action${actionNode.typeName}未以Action结尾`);
assert(stateNode.typeName.text.endsWith('State'), `文件${filename}中的ActionDef${name.text}所引用的State${stateNode.typeName}未以State结尾`);
assert(ts.isIdentifier(name),
`${filename}」中的 ActionDef「${(<ts.Identifier>name).text}」不是有效的变量名`);
assert(name.text.endsWith('ActionDef'),
`${filename}」中的变量「${name.text}」命名错误ActionDef 变量必须以 'ActionDef' 结尾`);
assert(ts.isTypeReferenceNode(actionNode) && ts.isTypeReferenceNode(stateNode),
`${filename}」中的「${name.text}」类型参数错误Action 和 State 必须是类型引用`);
assert(ts.isIdentifier(actionNode.typeName) && ts.isIdentifier(stateNode.typeName),
`${filename}」中的「${name.text}」类型参数错误Action 和 State 必须是简单标识符`);
assert(actionNode.typeName.text.endsWith('Action'),
`${filename}」中的「${name.text}」引用的 Action 类型「${actionNode.typeName.text}」命名错误:必须以 'Action' 结尾`);
assert(stateNode.typeName.text.endsWith('State'),
`${filename}」中的「${name.text}」引用的 State 类型「${stateNode.typeName.text}」命名错误:必须以 'State' 结尾`);
const adfName = name.text.slice(0, name.text.length - 9);
const aName = actionNode.typeName.text.slice(0, actionNode.typeName.text.length - 6);
const sName = stateNode.typeName.text.slice(0, stateNode.typeName.text.length - 5);
assert(adfName === aName && aName === sName, `文件${filename}中的ActionDef${name.text}中ActionDef, Action和State的命名规则不一致 需要
${adfName}ActionDef, ${adfName}Action, ${adfName}State`);
assert(adfName === aName && aName === sName,
`${filename}」中的「${name.text}」命名不一致。\n` +
`要求ActionDef、Action、State 的前缀必须相同\n` +
`当前:${name.text}, ${actionNode.typeName.text}, ${stateNode.typeName.text}\n` +
`应为:${adfName}ActionDef, ${adfName}Action, ${adfName}State`);
}
function checkStringLiteralLegal(filename: string, obj: string, text: string, ele: ts.TypeNode) {
assert(ts.isLiteralTypeNode(ele) && ts.isStringLiteral(ele.literal), `${filename}中引用的${obj} ${text}中存在不是stringliteral的类型`);
assert(!ele.literal.text.includes('$'), `${filename}中引用的action${text}中的${obj}${ele.literal.text}」包含非法字符$`);
assert(ele.literal.text.length > 0, `${filename}中引用的action${text}中的${obj}${ele.literal.text}」长度非法`);
assert(ele.literal.text.length < STRING_LITERAL_MAX_LENGTH, `${filename}中引用的${obj} ${text}中的「${ele.literal.text}」长度过长`);
assert(ts.isLiteralTypeNode(ele) && ts.isStringLiteral(ele.literal),
`${filename}」中的 ${obj}${text}」类型错误:必须是字符串字面量类型,如 'create' | 'update'`);
assert(!ele.literal.text.includes('$'),
`${filename}」中的 ${obj}${text}」包含非法字符:「${ele.literal.text}」中不能包含 '$' 字符`);
assert(ele.literal.text.length > 0,
`${filename}」中的 ${obj}${text}」不能为空字符串`);
assert(ele.literal.text.length < STRING_LITERAL_MAX_LENGTH,
`${filename}」中的 ${obj}${text}」的值「${ele.literal.text}」长度超限。\n` +
`当前长度:${ele.literal.text.length},最大允许:${STRING_LITERAL_MAX_LENGTH}`);
return ele.literal.text;
}
@ -576,14 +592,18 @@ function dealWithActionTypeNode(moduleName: string, filename: string, actionType
ele => !!ele
);
assert(intersection(actionNames, RESERVED_ACTION_NAMES).length === 0,
`${filename}中的Action命名不能是「${RESERVED_ACTION_NAMES.join(',')}」之一`);
`${filename}」中的 Action 命名冲突:不能使用保留名称。\n` +
`保留名称:${RESERVED_ACTION_NAMES.join(', ')}\n` +
`冲突的名称:${intersection(actionNames, RESERVED_ACTION_NAMES).join(', ')}`);
actionTypeNode.types.forEach(
ele => {
if (ts.isTypeReferenceNode(ele)) {
// 这里递归了类型定义去查找所有的action字符串
const actionStrings = tryGetStringLiteralValues(moduleName, filename, 'action', ele, program);
assert(actionStrings.length > 0, `${filename}中的Action所引用的Type定义为空`);
assert(actionStrings.length > 0,
`${filename}」中的 Action 类型「${(<ts.Identifier>ele.typeName).text}」定义为空,必须至少包含一个 action`);
actionTexts.push(...actionStrings);
}
else {
@ -595,8 +615,10 @@ function dealWithActionTypeNode(moduleName: string, filename: string, actionType
}
else if (ts.isTypeReferenceNode(actionTypeNode)) {
if (ts.isIdentifier(actionTypeNode.typeName)) {
assert(!RESERVED_ACTION_NAMES.includes(actionTypeNode.typeName.text),
`${filename}中的Action命名不能是「${RESERVED_ACTION_NAMES.join(',')}」之一`);
assert(intersection([actionTypeNode.typeName.text], RESERVED_ACTION_NAMES).length === 0,
`${filename}」中的 Action 命名冲突:不能使用保留名称。\n` +
`保留名称:${RESERVED_ACTION_NAMES.join(', ')}\n` +
`冲突的名称:${intersection([actionTypeNode.typeName.text], RESERVED_ACTION_NAMES).join(', ')}`);
}
const actionStrings = tryGetStringLiteralValues(moduleName, filename, 'action', actionTypeNode, program);
assert(actionStrings.length > 0, `${filename}中的Action定义为空`);
@ -611,8 +633,11 @@ function dealWithActionTypeNode(moduleName: string, filename: string, actionType
const ActionDict = {};
actionTexts.forEach(
(action) => {
assert(action.length <= STRING_LITERAL_MAX_LENGTH, `${filename}中的Action「${action}」命名长度大于${STRING_LITERAL_MAX_LENGTH}`);
assert(/^[a-z][a-z|A-Z]+$/.test(action), `${filename}中的Action「${action}」命名不合法,必须以小字字母开头且只能包含字母`)
assert(action.length <= STRING_LITERAL_MAX_LENGTH,
`${filename}」中的 Action「${action}」命名长度超限。\n` +
`当前长度:${action.length},最大允许:${STRING_LITERAL_MAX_LENGTH}`);
assert(/^[a-z][a-zA-Z]+$/.test(action),
`${filename}」中的 Action「${action}」命名格式错误:必须以小写字母开头且只能包含字母`);
if (ActionDict.hasOwnProperty(action)) {
throw new Error(`文件${filename}Action定义上的【${action}】动作存在同名`);
}
@ -689,7 +714,7 @@ function dealWithActionDefInitializer(moduleName: string, initializer: ts.Expres
if (existsSync(jsFileName)) {
try {
const jsContent = require('fs').readFileSync(jsFileName, 'utf-8');
// 获取原始的导出名称(处理 as 别名的情况)
// 如果有 propertyName说明使用了 as应该使用 propertyName
// 否则使用 name
@ -903,15 +928,21 @@ function checkLocaleExpressionPropertyExists(root: ts.ObjectLiteralExpression, a
} */
function checkNameLegal(filename: string, attrName: string, upperCase?: boolean) {
assert(attrName.length <= ENTITY_NAME_MAX_LENGTH, `文件「${filename}」:「${attrName}」的名称定义过长,不能超过「${ENTITY_NAME_MAX_LENGTH}」长度`);
assert(attrName.length <= ENTITY_NAME_MAX_LENGTH,
`${filename}」中的名称「${attrName}」长度超限。\n` +
`当前长度:${attrName.length},最大允许:${ENTITY_NAME_MAX_LENGTH}`);
if (upperCase) {
assert(/[A-Z][a-z|A-Z|0-9]+/i.test(attrName), `文件「${filename}」:「${attrName}」的名称必须以大写字母开始,且只能包含字母和数字`);
assert(/[A-Z][a-z|A-Z|0-9]+/i.test(attrName),
`${filename}」中的名称「${attrName}」格式错误必须以大写字母开头只能包含字母和数字UserProfile`);
}
else if (upperCase === false) {
assert(/[a-z][a-z|A-Z|0-9]+/i.test(attrName), `文件「${filename}」:「${attrName}」的名称必须以小写字母开始,且只能包含字母和数字`);
assert(/[a-z][a-z|A-Z|0-9]+/i.test(attrName),
`${filename}」中的名称「${attrName}」格式错误必须以小写字母开头只能包含字母和数字userName`);
}
else {
assert(/[a-z|A-Z][a-z|A-Z|0-9]+/i.test(attrName), `文件「${filename}」:「${attrName}」的名称必须以字母开始,且只能包含字母和数字`);
assert(/[a-z|A-Z][a-z|A-Z|0-9]+/i.test(attrName),
`${filename}」中的名称「${attrName}」格式错误:必须以字母开头,只能包含字母和数字`);
}
}
@ -1136,7 +1167,9 @@ function analyzeSchemaDefinition(
let toLog = false;
const extendsFrom: string[] = [];
const { members, heritageClauses } = node;
assert(heritageClauses, `${filename}」中的Schema定义需要继承EntityShape或者其他Entity`);
assert(heritageClauses,
`${filename}」中的 Schema 定义错误:必须继承 EntityShape 或其他实体。\n` +
`示例export interface Schema extends EntityShape { ... }`);
const heritagedResult = heritageClauses.map(
(clause) => {
@ -1148,7 +1181,9 @@ function analyzeSchemaDefinition(
}
// 从其它文件的Schema类继承这里需要将所继承的对象的属性进行访问并展开
assert(referencedSchemas.includes(expression.text), `${filename}」中的Schema继承了未定义的实体「${expression.text}`);
assert(referencedSchemas.includes(expression.text),
`${filename}」中的 Schema 继承了未导入的实体「${expression.text}」。\n` +
`提示请先导入该实体import { Schema as ${expression.text} } from '...'`);
const checker = program.getTypeChecker();
const symbol = checker.getSymbolAtLocation(expression);
let declaration = symbol?.getDeclarations()![0]!;
@ -1200,12 +1235,31 @@ function analyzeSchemaDefinition(
else if (type.typeName.text === 'Array') {
// 这是一对多的反向指针的引用,需要特殊处理
const { typeArguments } = type;
assert(typeArguments!.length === 1
&& ts.isTypeReferenceNode(typeArguments![0])
&& ts.isIdentifier(typeArguments![0].typeName)
&& referencedSchemas.includes(typeArguments![0].typeName.text),
`${filename}」非法的属性定义「${attrName}`);
const reverseEntity = typeArguments![0].typeName.text;
assert(
typeArguments && typeArguments.length === 1,
`${filename}」的属性「${attrName}Array 类型必须有且仅有一个类型参数`
);
const elementType = typeArguments[0];
assert(
ts.isTypeReferenceNode(elementType),
`${filename}」的属性「${attrName}Array<${elementType.getText()}> 的元素类型必须是实体引用,用于记录反向指针关系。\n` +
`提示:\n` +
` - 如需存储字符串数组,请使用 string[] 类型或者自定义类型存储 JSONtags?: string[]\n` +
` - 如需关联实体请使用实体类型attachments?: Array<ExtraFile>`
);
assert(
ts.isIdentifier(elementType.typeName),
`${filename}」的属性「${attrName}Array 的元素类型必须是简单标识符`
);
const elementTypeName = elementType.typeName.text;
assert(
referencedSchemas.includes(elementTypeName),
`${filename}」的属性「${attrName}Array<${elementTypeName}> 中的 ${elementTypeName} 不是有效的实体引用。\n` +
`提示:\n` +
` - 请确保已导入该实体import { Schema as ${elementTypeName} } from '...';\n` +
` - 或改用 Object 类型或者自定义类型存储 JSON 数据`
);
const reverseEntity = elementTypeName;
if (ReversePointerRelations[reverseEntity]) {
if (!ReversePointerRelations[reverseEntity].includes(moduleName)) {
ReversePointerRelations[reverseEntity].push(moduleName);
@ -1403,7 +1457,9 @@ function analyzeReferenceSchemaFile(
}
});
assert(result, `${filename}」中没有找到Schema定义`);
assert(result,
`${filename}」中缺少 Schema 定义。\n` +
`提示:每个实体文件必须包含 'export interface Schema extends EntityShape { ... }'`);
return result;
}
@ -2802,7 +2858,9 @@ function constructFilter(statements: Array<ts.Statement>, entity: string) {
}
else {
// 此时应当是引用本地定义的shape
assert(type, `${entity}中的属性${name.toString()}有非法的属性类型定义`);
assert(type,
`${entity}」的属性「${name.toString()}」缺少类型定义。\n` +
`提示每个属性都必须明确指定类型name: String<32>`);
members.push(
factory.createPropertySignature(
undefined,
@ -7670,7 +7728,9 @@ export function constructAttributes(entity: string): ts.PropertyAssignment[] {
if (ts.isUnionTypeNode(type!)) {
if (ts.isLiteralTypeNode(type.types[0])) {
if (ts.isStringLiteral(type.types[0].literal)) {
assert(enumAttributes && enumAttributes[(<ts.Identifier>name).text], `${entity}对象中的${(<ts.Identifier>name).text}属性没有定义enumAttributes`);
assert(enumAttributes && enumAttributes[(<ts.Identifier>name).text],
`${entity}」的属性「${(<ts.Identifier>name).text}」缺少枚举值定义。\n` +
`提示:枚举类型属性需要在 enumAttributes 中定义可选值`);
attrAssignments.push(
factory.createPropertyAssignment(
'type',
@ -7687,7 +7747,8 @@ export function constructAttributes(entity: string): ts.PropertyAssignment[] {
);
}
else {
assert(ts.isNumericLiteral(type.types[0].literal), `${entity}对象中的${type.types[0].literal.getText()}属性不是数字`);
assert(ts.isNumericLiteral(type.types[0].literal),
`${entity}」的属性类型错误:「${type.types[0].literal.getText()}」不是有效的数字字面量`);
attrAssignments.push(
factory.createPropertyAssignment(
factory.createIdentifier("type"),
@ -7739,7 +7800,8 @@ export function constructAttributes(entity: string): ts.PropertyAssignment[] {
);
}
else {
assert(ts.isNumericLiteral(type.literal), `${entity}对象中的${(<ts.Identifier>name).text}属性不是数字`);
assert(ts.isNumericLiteral(type.literal),
`${entity}」的属性「${(<ts.Identifier>name).text}」类型错误:必须是数字字面量类型`);
attrAssignments.push(
factory.createPropertyAssignment(
factory.createIdentifier("type"),

View File

@ -240,6 +240,8 @@ interface OakBuildChecksConfig {
tFunctionModules?: string[]; // t 函数所在模块,默认 ['oak-frontend-base']
checkTemplateLiterals?: boolean; // 是否检查模板字符串中的 i18n 键,默认禁用
warnStringKeys?: boolean; // 是否警告字符串形式的 i18n 键,默认禁用
checkJsxLiterals?: boolean; // 是否检查 JSX 中的字面量文本
jsxLiteralPattern?: string; // 用于匹配需要国际化的文本模式,默认匹配中文
}
}
@ -3175,6 +3177,186 @@ export function performCustomChecks(
return diagnostics;
}
const checkJsxLiterals = (
pwd: string,
program: ts.Program,
typeChecker: ts.TypeChecker,
customConfig: OakBuildChecksConfig
): CustomDiagnostic[] => {
const diagnostics: CustomDiagnostic[] = [];
// 默认匹配中文字符的正则
const defaultPattern = /[\u4e00-\u9fa5]/;
const patternStr = customConfig.locale?.jsxLiteralPattern;
const pattern = patternStr ? new RegExp(patternStr) : defaultPattern;
const addDiagnostic = (
node: ts.Node,
text: string,
messageText: string,
code: number
): void => {
const sourceFile = node.getSourceFile();
diagnostics.push({
file: sourceFile,
start: node.getStart(sourceFile),
length: node.getWidth(sourceFile),
messageText,
category: ts.DiagnosticCategory.Warning,
code,
reason: 'HardcodedJsxText',
reasonDetails: [
`✘ 发现硬编码文本: "${text}"`,
'建议: 使用 t() 函数进行国际化处理'
]
});
};
// 检查文本是否需要国际化
const needsI18n = (text: string): boolean => {
// 移除前后空白
const trimmed = text.trim();
// 忽略空字符串
if (trimmed.length === 0) return false;
// 忽略纯数字
if (/^\d+$/.test(trimmed)) return false;
// 忽略纯标点符号和特殊字符
if (/^[^\w\u4e00-\u9fa5]+$/.test(trimmed)) return false;
// 匹配需要国际化的模式
return pattern.test(trimmed);
};
// 遍历所有源文件
for (const sourceFile of program.getSourceFiles()) {
// 跳过声明文件
if (sourceFile.isDeclarationFile) continue;
// 只检查 tsx 文件
if (!sourceFile.fileName.endsWith('.tsx')) continue;
// 检查是否在检查范围内
if (!isFileInCheckScope(pwd, sourceFile.fileName, customConfig)) continue;
const visit = (node: ts.Node): void => {
// 检查 JsxText 节点
if (ts.isJsxText(node)) {
// 检查是否有忽略注释
if (hasIgnoreComment(node, sourceFile)) {
return;
}
const text = node.getText(sourceFile);
if (needsI18n(text)) {
addDiagnostic(
node,
text.trim(),
`JSX 文本包含需要国际化的内容: "${text.trim()}"`,
9300
);
}
}
// 检查 JsxAttribute 节点
if (ts.isJsxAttribute(node)) {
// 检查是否有忽略注释
if (hasIgnoreComment(node, sourceFile)) {
return;
}
const initializer = node.initializer;
if (!initializer) {
ts.forEachChild(node, visit);
return;
}
// 情况1: 直接字符串字面量 desc="text"
if (ts.isStringLiteral(initializer)) {
const text = initializer.text;
if (needsI18n(text)) {
addDiagnostic(
initializer,
text,
`JSX 属性包含需要国际化的内容: ${node.name.getText()}="${text}"`,
9301
);
}
}
// 情况2: JSX 表达式 desc={'text'} 或 desc={`text`}
else if (ts.isJsxExpression(initializer)) {
const expression = initializer.expression;
if (!expression) {
ts.forEachChild(node, visit);
return;
}
// 检查字符串字面量
if (ts.isStringLiteral(expression)) {
const text = expression.text;
if (needsI18n(text)) {
addDiagnostic(
expression,
text,
`JSX 属性包含需要国际化的内容: ${node.name.getText()}={'${text}'}`,
9301
);
}
}
// 检查模板字面量(无替换)
else if (ts.isNoSubstitutionTemplateLiteral(expression)) {
const text = expression.text;
if (needsI18n(text)) {
addDiagnostic(
expression,
text,
`JSX 属性包含需要国际化的内容: ${node.name.getText()}={\`${text}\`}`,
9301
);
}
}
// 检查模板表达式(有替换) - 可选的更严格检查
else if (ts.isTemplateExpression(expression)) {
// 检查模板头部
const headText = expression.head.text;
if (needsI18n(headText)) {
addDiagnostic(
expression.head,
headText,
`JSX 属性的模板字符串包含需要国际化的内容: ${node.name.getText()}`,
9302
);
}
// 检查每个模板片段
expression.templateSpans.forEach(span => {
const spanText = span.literal.text;
if (needsI18n(spanText)) {
addDiagnostic(
span.literal,
spanText,
`JSX 属性的模板字符串包含需要国际化的内容: ${node.name.getText()}`,
9302
);
}
});
}
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
}
return diagnostics;
};
// 信息收集
for (const sourceFile of program.getSourceFiles()) {
if (sourceFile.isDeclarationFile) {
@ -3204,7 +3386,16 @@ export function performCustomChecks(
const i18nDiagnostics = checkI18nKeys(pwd, tFunctionSymbolCache, program, typeChecker);
return diagnostics.concat(i18nDiagnostics);
let jsxLiteralDiagnostics: CustomDiagnostic[] = [];
const enableJsxLiteralCheck = customConfig.locale?.checkJsxLiterals === true;
if (enableJsxLiteralCheck) {
console.log(`Custom JSX literal checks are ${colors.green + 'enabled' + colors.reset}.`);
jsxLiteralDiagnostics = checkJsxLiterals(pwd, program, typeChecker, customConfig);
} else {
console.log(`Custom JSX literal checks are ${colors.gray + 'disabled' + colors.reset}.`);
}
return diagnostics.concat(i18nDiagnostics).concat(jsxLiteralDiagnostics);
}
const loadI18nData = (dirPath: string): Record<string, string> | null => {