Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/devextreme/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions packages/devextreme/eslint_plugins/index.js
Original file line number Diff line number Diff line change
@@ -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,
},
};
79 changes: 79 additions & 0 deletions packages/devextreme/eslint_plugins/jsdoc_default_matches_type.js
Original file line number Diff line number Diff line change
@@ -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' });
}
},
};
},
};
Original file line number Diff line number Diff line change
@@ -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' }],
},
],
});
Loading