diff --git a/eslint-rules/enforce-catch-type.js b/eslint-rules/enforce-catch-type.js new file mode 100644 index 0000000..ec01471 --- /dev/null +++ b/eslint-rules/enforce-catch-type.js @@ -0,0 +1,62 @@ +export const enforceCatchType = { + meta: { + type: "problem", + docs: { + description: + "强制 catch 子句使用 e: unknown,并用 instanceof Error 提取错误信息;空的 catch 块应添加注释说明原因", + }, + messages: { + missingTypeAnnotation: + "catch 子句缺少类型注解。请使用 catch (e: unknown),然后用 e instanceof Error ? e.message : String(e) 提取错误信息。", + nonUnknownType: + "catch 的类型注解应为 unknown,切勿使用 any。请改为 catch (e: unknown),然后用 e instanceof Error ? e.message : String(e) 提取错误信息。", + emptyCatchNoComment: + "空的 catch 块应添加注释说明为什么忽略此异常。如果确有理由静默吞掉错误,请在该 catch body 内添加注释。", + }, + schema: [], + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + function isUnknownType(typeAnnotation) { + if (!typeAnnotation) return false; + const type = typeAnnotation.typeAnnotation; + if (type?.type === "TSTypeReference") { + return type.typeName?.name === "unknown"; + } + return false; + } + + function hasCommentsInBody(body) { + if (!body) return false; + return sourceCode.getCommentsInside(body).length > 0; + } + + function check(node) { + const { param, body } = node; + + if (param) { + const typeAnnotation = param.typeAnnotation; + if (!typeAnnotation) { + context.report({ node: param, messageId: "missingTypeAnnotation" }); + } else if (!isUnknownType(typeAnnotation)) { + context.report({ node: typeAnnotation, messageId: "nonUnknownType" }); + } + } + + if ( + body && + body.type === "BlockStatement" && + body.body.length === 0 && + !hasCommentsInBody(body) + ) { + context.report({ node: body, messageId: "emptyCatchNoComment" }); + } + } + + return { + CatchClause: check, + }; + }, +}; \ No newline at end of file diff --git a/eslint-rules/no-empty-function.js b/eslint-rules/no-empty-function.js new file mode 100644 index 0000000..2d9072b --- /dev/null +++ b/eslint-rules/no-empty-function.js @@ -0,0 +1,72 @@ +export const noEmptyFunction = { + meta: { + type: "problem", + docs: { + description: + "禁止空函数体,并提供项目约定的修复指引:生产代码使用 () => undefined,测试代码使用 () => {} + eslint-disable", + }, + messages: { + unexpectedProduction: + "生产代码中空函数应使用 () => undefined 明确表意(如 noop/voidLog)。如果确需空实现且为接口契约,请添加注释说明原因。", + unexpectedTest: + "测试代码中空函数使用 () => {} 并在文件顶部添加 /* eslint-disable @typescript-eslint/no-empty-function */。", + }, + schema: [], + }, + + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + const allowedFunctionTypes = new Set([ + "ArrowFunctionExpression", + "FunctionDeclaration", + "FunctionExpression", + ]); + + function isEmptyBody(body) { + return ( + body.type === "BlockStatement" && + body.body.length === 0 && + sourceCode.getCommentsInside(body).length === 0 + ); + } + + function hasDecorator(node) { + return Array.isArray(node.decorators) && node.decorators.length > 0; + } + + function isPrivateOrProtectedConstructor(node) { + if (node.parent?.type !== "MethodDefinition") return false; + if (node.parent.kind !== "constructor") return false; + const accessibility = node.parent.accessibility; + return accessibility === "private" || accessibility === "protected"; + } + + function isOverrideMethod(node) { + if (node.parent?.type !== "MethodDefinition") return false; + return node.parent.override === true; + } + + function check(node) { + if (!allowedFunctionTypes.has(node.type)) return; + if (!isEmptyBody(node.body)) return; + if (hasDecorator(node)) return; + if (isPrivateOrProtectedConstructor(node)) return; + if (isOverrideMethod(node)) return; + + const isTest = /[\\/]tests?[\\/]/.test(context.filename ?? "") || context.filename?.includes("test"); + + context.report({ + node, + messageId: isTest ? "unexpectedTest" : "unexpectedProduction", + data: { name: node.id?.name ?? node.key?.name ?? "function" }, + }); + } + + return { + ArrowFunctionExpression: check, + FunctionDeclaration: check, + FunctionExpression: check, + }; + }, +}; diff --git a/eslint.config.js b/eslint.config.js index 735e917..bce5e73 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -6,6 +6,9 @@ import reactHooks from "eslint-plugin-react-hooks"; import reactRefresh from "eslint-plugin-react-refresh"; import tseslint from "typescript-eslint"; +import { enforceCatchType } from "./eslint-rules/enforce-catch-type.js"; +import { noEmptyFunction } from "./eslint-rules/no-empty-function.js"; + const noDirectConsoleMessage = "后端运行时代码禁止直接使用 console.*;请通过注入的 Logger 实例输出日志,配置加载失败前使用 createConsoleFallback()。"; @@ -28,6 +31,7 @@ export default tseslint.config( "bin/**", "bun.lock", "data/**", + "eslint-rules/**", ], }, js.configs.recommended, @@ -52,14 +56,38 @@ export default tseslint.config( "@typescript-eslint/array-type": ["error", { default: "array-simple" }], "@typescript-eslint/consistent-type-assertions": ["error", { assertionStyle: "as" }], "@typescript-eslint/consistent-type-imports": ["error", { prefer: "type-imports" }], + "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], "@typescript-eslint/only-throw-error": "error", "@typescript-eslint/prefer-nullish-coalescing": "error", "@typescript-eslint/prefer-optional-chain": "error", "import/no-unresolved": ["error", { ignore: ["^bun:"] }], + "no-restricted-syntax": [ + "error", + { + message: + "禁止 throw 字面量。项目约定只允许 throw new Error(...) 或 throw new AppError(msg, statusCode)。Re-throw 已捕获的 Error 实例时使用 throw e。", + selector: "ThrowStatement > Literal", + }, + ], "no-undef": "off", }, }, + { + files: ["src/**/*.{ts,tsx}"], + plugins: { + local: { + rules: { + "enforce-catch-type": enforceCatchType, + "no-empty-function": noEmptyFunction, + }, + }, + }, + rules: { + "local/enforce-catch-type": "warn", + "local/no-empty-function": "error", + }, + }, { files: ["eslint.config.js"], rules: {