diff --git a/eslint-local-rules/index.mjs b/eslint-local-rules/index.mjs index 98440d51cae..a82d18ab23a 100644 --- a/eslint-local-rules/index.mjs +++ b/eslint-local-rules/index.mjs @@ -16,6 +16,7 @@ import { TVariablesShouldExtendOperationVariables, TDataTVariablesOrder, } from "./generics.ts"; +import { enforceDocumentationTypes } from "./namespace-documentationTypes.ts"; export default { "require-using-disposable": requireUsingDisposable, @@ -31,4 +32,5 @@ export default { "variables-should-extend-operation-variables": TVariablesShouldExtendOperationVariables, "tdata-tvariables-order": TDataTVariablesOrder, + "enforce-documentation-types": enforceDocumentationTypes, }; diff --git a/eslint-local-rules/namespace-documentationTypes.ts b/eslint-local-rules/namespace-documentationTypes.ts new file mode 100644 index 00000000000..f1cec7c70f5 --- /dev/null +++ b/eslint-local-rules/namespace-documentationTypes.ts @@ -0,0 +1,72 @@ +import type { TSESTree as AST } from "@typescript-eslint/types"; +import { ESLintUtils } from "@typescript-eslint/utils"; + +export const enforceDocumentationTypes = ESLintUtils.RuleCreator.withoutDocs({ + create(context) { + const namespaces = []; + const shouldBeDocumented: Record< + string, + { + namespaces: string[]; + node: AST.TSTypeAliasDeclaration; + } + > = {}; + return { + TSModuleDeclaration(node) { + if (node.kind !== "namespace") { + return; + } + namespaces.push( + node.id.type === "Identifier" ? node.id.name : "" + ); + }, + "TSModuleDeclaration:exit"(node) { + if (node.kind !== "namespace") { + return; + } + namespaces.pop(); + for (const [name, entry] of Object.entries(shouldBeDocumented)) { + if (entry.namespaces.length > namespaces.length) { + delete shouldBeDocumented[name]; + context.report({ + node: entry.node.id, + messageId: "shouldBeDocumented", + }); + } + } + }, + ExportNamedDeclaration(node) { + if (!node.declaration) { + return; + } + if (node.declaration.type === "TSTypeAliasDeclaration") { + const name = node.declaration.id.name; + if (name.endsWith("Result") || name.endsWith("Options")) { + shouldBeDocumented[name] = { + node: node.declaration, + namespaces: [...namespaces], + }; + } + } else if (node.declaration.type === "TSInterfaceDeclaration") { + const name = node.declaration.id.name; + if ( + name in shouldBeDocumented && + namespaces.at(-1) === "DocumentationTypes" + ) { + delete shouldBeDocumented[name]; + } + } + }, + }; + }, + meta: { + messages: { + shouldBeDocumented: + "This type should have an interface with the same name in a nested `DocumentationTypes` namespace.", + }, + type: "problem", + schema: [], + fixable: "code", + }, + defaultOptions: [], +}); diff --git a/eslint.config.mjs b/eslint.config.mjs index 12d6ae0a0ed..3fb7d0ddf74 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -201,6 +201,7 @@ export default [ "local-rules/valid-inherit-doc": "error", "local-rules/variables-should-extend-operation-variables": "error", "local-rules/tdata-tvariables-order": "error", + "local-rules/enforce-documentation-types": "error", }, }, ...compat.extends("plugin:testing-library/react").map((config) => ({