From 8bfe9ed0b4d81a1b1b3e122f49a7dbeb30540942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?marker=20dao=20=C2=AE?= Date: Thu, 25 Jun 2026 19:54:38 +0200 Subject: [PATCH] ESLint: Add JSDoc default matches type plugin --- packages/devextreme/eslint.config.mjs | 1 + packages/devextreme/eslint_plugins/index.js | 3 + .../jsdoc_default_matches_type.js | 79 +++++++++++++++++++ .../jsdoc_default_matches_type.test.js | 64 +++++++++++++++ 4 files changed, 147 insertions(+) create mode 100644 packages/devextreme/eslint_plugins/jsdoc_default_matches_type.js create mode 100644 packages/devextreme/eslint_plugins/jsdoc_default_matches_type.test.js diff --git a/packages/devextreme/eslint.config.mjs b/packages/devextreme/eslint.config.mjs index 9338d6973791..bffa402e3518 100644 --- a/packages/devextreme/eslint.config.mjs +++ b/packages/devextreme/eslint.config.mjs @@ -379,6 +379,7 @@ export default [ '@typescript-eslint/prefer-interface': 'off', '@typescript-eslint/consistent-type-definitions': 'off', '@typescript-eslint/no-empty-interface': 'off', + 'devextreme-custom/jsdoc-default-matches-type': 'warn', }, }, // Rules for build folder diff --git a/packages/devextreme/eslint_plugins/index.js b/packages/devextreme/eslint_plugins/index.js index bb2639cd0edb..7794962e93c5 100644 --- a/packages/devextreme/eslint_plugins/index.js +++ b/packages/devextreme/eslint_plugins/index.js @@ -1,11 +1,14 @@ +/* eslint-disable spellcheck/spell-checker */ const noDirectPreactSignalsCoreImport = require('./no_direct_preact_signals_core_import'); const preferSwitchTrue = require('./prefer_switch_true'); const noDeferred = require('./no_deferred'); +const jsdocDefaultMatchesType = require('./jsdoc_default_matches_type'); module.exports = { rules: { 'no-direct-preact-signals-core-import': noDirectPreactSignalsCoreImport, 'prefer-switch-true': preferSwitchTrue, 'no-deferred': noDeferred, + 'jsdoc-default-matches-type': jsdocDefaultMatchesType, }, }; diff --git a/packages/devextreme/eslint_plugins/jsdoc_default_matches_type.js b/packages/devextreme/eslint_plugins/jsdoc_default_matches_type.js new file mode 100644 index 000000000000..f47bb8453834 --- /dev/null +++ b/packages/devextreme/eslint_plugins/jsdoc_default_matches_type.js @@ -0,0 +1,79 @@ +function readDefaultToken(node, sourceCode) { + const comments = sourceCode.getCommentsBefore(node); + for(let i = comments.length - 1; i >= 0; i -= 1) { + if(comments[i].type === 'Block') { + const match = /@default\s+(\S+)/.exec(comments[i].value); + return match ? match[1] : null; + } + } + return null; +} + +function classifyDefault(token) { + if(token === null) { + return 'none'; + } + if(token === 'null' || token === 'undefined') { + return token; + } + return 'concrete'; +} + +function getTopLevelMembers(typeNode) { + return typeNode.type === 'TSUnionType' ? typeNode.types : [typeNode]; +} + +function hasNullMember(typeNode) { + return getTopLevelMembers(typeNode).some((member) => member.type === 'TSNullKeyword'); +} + +function hasUndefinedMember(typeNode) { + return getTopLevelMembers(typeNode).some((member) => member.type === 'TSUndefinedKeyword'); +} + +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Keep a property\'s JSDoc @default consistent with the shape of its type', + recommended: false, + }, + schema: [], + messages: { + defaultNullNeedsNull: '`@default null` is set, but the type has no `null`. Add `| null` to the type, or correct the @default.', + concreteDefaultNoUndefined: '`@default {{value}}` is concrete, but the type includes `| undefined`. Remove `| undefined` — an option with a real default never holds `undefined`.', + defaultUndefinedNeedsUndefined: '`@default undefined` is set, but the type has no `| undefined`. Either add `| undefined` (the value is genuinely unset by default — the wrapper generator drops `?`), or correct `@default` to the real stored value (e.g. `{}` for an always-present object option).', + }, + }, + create(context) { + const sourceCode = context.sourceCode ?? context.getSourceCode(); + + return { + TSPropertySignature(node) { + const typeNode = node.typeAnnotation && node.typeAnnotation.typeAnnotation; + if(!typeNode) { + return; + } + + const token = readDefaultToken(node, sourceCode); + const kind = classifyDefault(token); + + if(kind === 'null' && !hasNullMember(typeNode)) { + context.report({ node: node.key, messageId: 'defaultNullNeedsNull' }); + } + + if(kind === 'concrete' && hasUndefinedMember(typeNode)) { + context.report({ + node: node.key, + messageId: 'concreteDefaultNoUndefined', + data: { value: token }, + }); + } + + if(kind === 'undefined' && !hasUndefinedMember(typeNode)) { + context.report({ node: node.key, messageId: 'defaultUndefinedNeedsUndefined' }); + } + }, + }; + }, +}; diff --git a/packages/devextreme/eslint_plugins/jsdoc_default_matches_type.test.js b/packages/devextreme/eslint_plugins/jsdoc_default_matches_type.test.js new file mode 100644 index 000000000000..2d1984741ac9 --- /dev/null +++ b/packages/devextreme/eslint_plugins/jsdoc_default_matches_type.test.js @@ -0,0 +1,64 @@ +/* eslint-disable spellcheck/spell-checker */ +const { RuleTester } = require('eslint'); +const tsParser = require('@typescript-eslint/parser'); +const rule = require('./jsdoc_default_matches_type'); + +const ruleTester = new RuleTester({ + languageOptions: { + parser: tsParser, + ecmaVersion: 2020, + sourceType: 'module', + }, +}); + +ruleTester.run('jsdoc-default-matches-type', rule, { + valid: [ + { + code: 'interface dxFooOptions { /** @default null */ foo?: string | null; }', + filename: 'foo.d.ts', + }, + { + code: 'interface dxFooOptions { /** @default false */ foo?: boolean; }', + filename: 'foo.d.ts', + }, + { + code: 'interface dxFooOptions { /** @default undefined */ foo?: string | undefined; }', + filename: 'foo.d.ts', + }, + { + code: 'interface dxFooOptions { /** @docid */ bar?: PopupProperties; }', + filename: 'foo.d.ts', + }, + { + code: 'interface dxFooOptions { /** @docid */ foo?: string; }', + filename: 'foo.d.ts', + }, + { + code: 'interface dxFooOptions { /** @default undefined */ bar?: PopupProperties | undefined; }', + filename: 'foo.d.ts', + }, + ], + + invalid: [ + { + code: 'interface dxFooOptions { /** @default null */ foo?: string; }', + filename: 'foo.d.ts', + errors: [{ messageId: 'defaultNullNeedsNull' }], + }, + { + code: 'interface dxFooOptions { /** @default false */ foo?: boolean | undefined; }', + filename: 'foo.d.ts', + errors: [{ messageId: 'concreteDefaultNoUndefined' }], + }, + { + code: 'interface dxFooOptions { /** @default undefined */ foo?: string; }', + filename: 'foo.d.ts', + errors: [{ messageId: 'defaultUndefinedNeedsUndefined' }], + }, + { + code: 'interface dxFooOptions { /** @default undefined */ bar?: { x?: number; }; }', + filename: 'foo.d.ts', + errors: [{ messageId: 'defaultUndefinedNeedsUndefined' }], + }, + ], +});