From 5877cbaf444dc6d14510325cca23b29ab190229c Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Wed, 19 Nov 2025 14:09:54 -0800 Subject: [PATCH 1/2] feat(eslint): add plugin, add expect method call lint rule COMPASS-10060 --- configs/eslint-config-devtools/common.js | 5 +- configs/eslint-config-devtools/index.js | 1 + configs/eslint-config-devtools/package.json | 1 + configs/eslint-plugin-devtools/.depcheckrc | 3 + configs/eslint-plugin-devtools/.eslintignore | 2 + configs/eslint-plugin-devtools/.eslintrc.js | 9 + configs/eslint-plugin-devtools/.mocharc.js | 6 + .../eslint-plugin-devtools/.prettierrc.json | 1 + configs/eslint-plugin-devtools/index.js | 6 + configs/eslint-plugin-devtools/package.json | 36 +++ .../rules/no-expect-method-without-call.js | 87 ++++++++ .../no-expect-method-without-call.test.js | 208 ++++++++++++++++++ package-lock.json | 30 +++ 13 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 configs/eslint-plugin-devtools/.depcheckrc create mode 100644 configs/eslint-plugin-devtools/.eslintignore create mode 100644 configs/eslint-plugin-devtools/.eslintrc.js create mode 100644 configs/eslint-plugin-devtools/.mocharc.js create mode 100644 configs/eslint-plugin-devtools/.prettierrc.json create mode 100644 configs/eslint-plugin-devtools/index.js create mode 100644 configs/eslint-plugin-devtools/package.json create mode 100644 configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js create mode 100644 configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js diff --git a/configs/eslint-config-devtools/common.js b/configs/eslint-config-devtools/common.js index 1d52f1ec..96ad10ef 100644 --- a/configs/eslint-config-devtools/common.js +++ b/configs/eslint-config-devtools/common.js @@ -36,6 +36,7 @@ const testRules = { 'mocha/no-setup-in-describe': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-empty-function': 'off', + '@mongodb-js/devtools/no-expect-method-without-call': 'error', }; const jsConfigurations = ['eslint:recommended']; @@ -124,7 +125,9 @@ const testOverrides = { ], env: { mocha: true }, extends: [...testConfigurations], - rules: { ...testRules }, + rules: { + ...testRules, + }, }; module.exports = { diff --git a/configs/eslint-config-devtools/index.js b/configs/eslint-config-devtools/index.js index 418b2e00..f6afefc9 100644 --- a/configs/eslint-config-devtools/index.js +++ b/configs/eslint-config-devtools/index.js @@ -10,6 +10,7 @@ module.exports = { 'react', 'react-hooks', 'filename-rules', + '@mongodb-js/devtools', ], rules: { 'filename-rules/match': ['error', common.kebabcase], diff --git a/configs/eslint-config-devtools/package.json b/configs/eslint-config-devtools/package.json index 130fd928..8a190ba5 100644 --- a/configs/eslint-config-devtools/package.json +++ b/configs/eslint-config-devtools/package.json @@ -18,6 +18,7 @@ "@babel/eslint-parser": "^7.22.7", "@babel/preset-env": "^7.22.7", "@babel/preset-react": "^7.22.5", + "@mongodb-js/eslint-plugin-devtools": "^0.1.0", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "eslint-config-prettier": "^8.3.0", diff --git a/configs/eslint-plugin-devtools/.depcheckrc b/configs/eslint-plugin-devtools/.depcheckrc new file mode 100644 index 00000000..98adc00a --- /dev/null +++ b/configs/eslint-plugin-devtools/.depcheckrc @@ -0,0 +1,3 @@ +ignores: + - '@mongodb-js/prettier-config-devtools' + - '@mongodb-js/eslint-config-devtools' diff --git a/configs/eslint-plugin-devtools/.eslintignore b/configs/eslint-plugin-devtools/.eslintignore new file mode 100644 index 00000000..85a8a75e --- /dev/null +++ b/configs/eslint-plugin-devtools/.eslintignore @@ -0,0 +1,2 @@ +.nyc-output +dist diff --git a/configs/eslint-plugin-devtools/.eslintrc.js b/configs/eslint-plugin-devtools/.eslintrc.js new file mode 100644 index 00000000..d510a655 --- /dev/null +++ b/configs/eslint-plugin-devtools/.eslintrc.js @@ -0,0 +1,9 @@ +'use strict'; +module.exports = { + root: true, + extends: ['@mongodb-js/eslint-config-devtools'], + parserOptions: { + tsconfigRootDir: __dirname, + project: ['./tsconfig-lint.json'], + }, +}; diff --git a/configs/eslint-plugin-devtools/.mocharc.js b/configs/eslint-plugin-devtools/.mocharc.js new file mode 100644 index 00000000..f8698b5e --- /dev/null +++ b/configs/eslint-plugin-devtools/.mocharc.js @@ -0,0 +1,6 @@ +'use strict'; +module.exports = { + ...require('@mongodb-js/mocha-config-devtools'), + spec: ['rules/**/*.test.js'], + watchFiles: ['rules/**/*.js'], +}; diff --git a/configs/eslint-plugin-devtools/.prettierrc.json b/configs/eslint-plugin-devtools/.prettierrc.json new file mode 100644 index 00000000..dfae21d0 --- /dev/null +++ b/configs/eslint-plugin-devtools/.prettierrc.json @@ -0,0 +1 @@ +"@mongodb-js/prettier-config-devtools" diff --git a/configs/eslint-plugin-devtools/index.js b/configs/eslint-plugin-devtools/index.js new file mode 100644 index 00000000..44598442 --- /dev/null +++ b/configs/eslint-plugin-devtools/index.js @@ -0,0 +1,6 @@ +'use strict'; +module.exports = { + rules: { + 'no-expect-method-without-call': require('./rules/no-expect-method-without-call'), + }, +}; diff --git a/configs/eslint-plugin-devtools/package.json b/configs/eslint-plugin-devtools/package.json new file mode 100644 index 00000000..16bd715a --- /dev/null +++ b/configs/eslint-plugin-devtools/package.json @@ -0,0 +1,36 @@ +{ + "name": "@mongodb-js/eslint-plugin-devtools", + "description": "Custom eslint rules for DevTools", + "homepage": "https://github.com/mongodb-js/devtools-shared", + "version": "0.1.0", + "repository": { + "type": "git", + "url": "https://github.com/mongodb-js/devtools-shared.git" + }, + "files": [ + "dist" + ], + "license": "SSPL", + "main": "index.js", + "scripts": { + "eslint": "eslint", + "prettier": "prettier", + "lint": "npm run eslint . && npm run prettier -- --check .", + "depcheck": "depcheck", + "check": "npm run lint && npm run depcheck", + "check-ci": "npm run check", + "test": "mocha", + "test-cov": "nyc --compact=false --produce-source-map=false -x \"**/*.spec.*\" --reporter=lcov --reporter=text --reporter=html npm run test", + "test-watch": "npm run test -- --watch", + "test-ci": "npm run test-cov", + "reformat": "npm run eslint . -- --fix && npm run prettier -- --write ." + }, + "devDependencies": { + "@mongodb-js/mocha-config-devtools": "^1.0.5", + "@mongodb-js/prettier-config-devtools": "^1.0.1", + "depcheck": "^1.4.7", + "eslint": "^7.25.0", + "mocha": "^8.4.0", + "nyc": "^15.1.0" + } +} diff --git a/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js new file mode 100644 index 00000000..305d6c44 --- /dev/null +++ b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js @@ -0,0 +1,87 @@ +'use strict'; + +const PROPERTY_TERMINATORS = [ + 'ok', + 'true', + 'false', + 'null', + 'undefined', + 'exist', + 'empty', + 'arguments', +]; + +function followNodeChainToExpectCall(node) { + while (node) { + if (node.type === 'CallExpression') { + if (node.callee.type === 'Identifier' && node.callee.name === 'expect') { + return node; + } + if (node.callee.type === 'MemberExpression') { + node = node.callee.object; + continue; + } + } + + // Continue on the node chain (e.g. .not, .to). + if (node.type === 'MemberExpression') { + node = node.object; + continue; + } + + break; + } +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: + 'Require invocation of expect method assertions (e.g., to.throw())', + }, + messages: { + methodNotInvoked: + 'expect().to.[METHOD] must be invoked with parentheses, e.g., expect(() => someFn()).to.throw()', + }, + }, + + create(context) { + return { + MemberExpression(node) { + if ( + node.type !== 'MemberExpression' || + PROPERTY_TERMINATORS.includes(node.property.name) || + node.property.name === 'expect' + ) { + return null; + } + + const isInvoked = + node.parent && + node.parent.type === 'CallExpression' && + node.parent.callee === node; + + const isPartOfLongerChain = + node.parent && + node.parent.type === 'MemberExpression' && + node.parent.object === node; + + if (isInvoked || isPartOfLongerChain) { + return null; + } + + const expectCall = followNodeChainToExpectCall(node); + if (!expectCall) { + return null; + } + + context.report({ + node, + messageId: 'methodNotInvoked', + }); + }, + }; + }, +}; diff --git a/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js new file mode 100644 index 00000000..671015e4 --- /dev/null +++ b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js @@ -0,0 +1,208 @@ +'use strict'; +const { RuleTester } = require('eslint'); +const rule = require('./no-expect-method-without-call'); + +const ruleTester = new RuleTester(); + +ruleTester.run('no-expect-method-without-call', rule, { + valid: [ + { + code: 'expect(5 > 3).to.not.be.false', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(5 > 3).to.be.true', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect("pineapple").to.be.a("string")', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(5 + 5).to.equal(10)', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.throw()', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.not.throw()', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.not.throws()', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.not.Throw()', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: "expect(() => { throw new Error('test'); }).to.throw()", + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: "expect(function() { throw new Error('test'); }).to.throw()", + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.throw(Error)', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.not.throw(Error)', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: "expect(() => someFn()).to.throw('error message')", + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(() => someFn()).to.throw(/error/)', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: ` +it('should test something', () => { + expect(() => dangerousFunction()).to.throw(); +});`, + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'const result = expect(() => fn()).to.throw()', + parserOptions: { ecmaVersion: 2021 }, + }, + // Valid - not using expect().to.throw. + { + code: 'expect(value).to.equal(5)', + parserOptions: { ecmaVersion: 2021 }, + }, + { + code: 'expect(promise).to.be.rejectedWith(Error)', + parserOptions: { ecmaVersion: 2021 }, + }, + ], + invalid: [ + { + code: 'expect(() => someFn()).to.throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect("pineapple").to.be.a', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect("pineapple").to.include', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect("pineapple").to.not.include', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect("pineapple").to.throw;', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect("pineapple").to.not.throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect(() => someFn()).to.not.throws', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect(() => someFn()).to.not.Throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect(() => someFn()).not.to.throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect(function() { throw new Error(); }).to.throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: ` +it('should test something', () => { + expect(() => dangerousFunction()).to.throw; +});`, + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'const result = expect(() => fn()).to.throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + { + code: 'expect(async () => someFn()).to.throw', + parserOptions: { ecmaVersion: 2021 }, + errors: [ + { + messageId: 'methodNotInvoked', + }, + ], + }, + ], +}); diff --git a/package-lock.json b/package-lock.json index ff482aa8..eec4d96d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@babel/eslint-parser": "^7.22.7", "@babel/preset-env": "^7.22.7", "@babel/preset-react": "^7.22.5", + "@mongodb-js/eslint-plugin-devtools": "^0.1.0", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "eslint-config-prettier": "^8.3.0", @@ -52,6 +53,19 @@ "eslint": "^7.25.0 || ^8.0.0" } }, + "configs/eslint-plugin-devtools": { + "name": "@mongodb-js/eslint-plugin-devtools", + "version": "0.1.0", + "license": "SSPL", + "devDependencies": { + "@mongodb-js/mocha-config-devtools": "^1.0.5", + "@mongodb-js/prettier-config-devtools": "^1.0.1", + "depcheck": "^1.4.7", + "eslint": "^7.25.0", + "mocha": "^8.4.0", + "nyc": "^15.1.0" + } + }, "configs/mocha-config-devtools": { "name": "@mongodb-js/mocha-config-devtools", "version": "1.0.5", @@ -7847,6 +7861,10 @@ "resolved": "configs/eslint-config-devtools", "link": true }, + "node_modules/@mongodb-js/eslint-plugin-devtools": { + "resolved": "configs/eslint-plugin-devtools", + "link": true + }, "node_modules/@mongodb-js/get-os-info": { "resolved": "packages/get-os-info", "link": true @@ -38853,6 +38871,7 @@ "@babel/eslint-parser": "^7.22.7", "@babel/preset-env": "^7.22.7", "@babel/preset-react": "^7.22.5", + "@mongodb-js/eslint-plugin-devtools": "^0.1.0", "@typescript-eslint/eslint-plugin": "^5.59.0", "@typescript-eslint/parser": "^5.59.0", "eslint-config-prettier": "^8.3.0", @@ -38864,6 +38883,17 @@ "prettier": "^3.5.3" } }, + "@mongodb-js/eslint-plugin-devtools": { + "version": "file:configs/eslint-plugin-devtools", + "requires": { + "@mongodb-js/mocha-config-devtools": "^1.0.5", + "@mongodb-js/prettier-config-devtools": "^1.0.1", + "depcheck": "^1.4.7", + "eslint": "^7.25.0", + "mocha": "^8.4.0", + "nyc": "^15.1.0" + } + }, "@mongodb-js/get-os-info": { "version": "file:packages/get-os-info", "requires": { From df0cbd2c198e613ecdd05e8e7185b2133c604fb6 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Thu, 20 Nov 2025 09:23:11 -0800 Subject: [PATCH 2/2] fixup: add more terminators, allow passing custom, add function name --- .../rules/no-expect-method-without-call.js | 44 ++++++++++++--- .../no-expect-method-without-call.test.js | 54 +++++++++++-------- 2 files changed, 71 insertions(+), 27 deletions(-) diff --git a/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js index 305d6c44..0057dcd5 100644 --- a/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js +++ b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.js @@ -1,6 +1,7 @@ 'use strict'; const PROPERTY_TERMINATORS = [ + // Default chai terminators. 'ok', 'true', 'false', @@ -9,6 +10,28 @@ const PROPERTY_TERMINATORS = [ 'exist', 'empty', 'arguments', + 'NaN', + 'extensible', + 'sealed', + 'frozen', + 'finite', + + // Terminators from chai-as-promised. + 'fulfilled', + 'rejected', + + // Terminators from sinon-chai. + 'called', + 'calledOnce', + 'calledTwice', + 'calledThrice', + 'calledWithNew', + + // Terminators from chai-dom. + 'displayed', + 'visible', + 'focus', + 'checked', ]; function followNodeChainToExpectCall(node) { @@ -41,18 +64,21 @@ module.exports = { description: 'Require invocation of expect method assertions (e.g., to.throw())', }, - messages: { - methodNotInvoked: - 'expect().to.[METHOD] must be invoked with parentheses, e.g., expect(() => someFn()).to.throw()', - }, }, create(context) { + const options = context.options[0] ?? {}; + const additionalTerminators = options.properties ?? []; + const validTerminators = [ + ...PROPERTY_TERMINATORS, + ...additionalTerminators, + ]; + return { MemberExpression(node) { if ( node.type !== 'MemberExpression' || - PROPERTY_TERMINATORS.includes(node.property.name) || + validTerminators.includes(node.property.name) || node.property.name === 'expect' ) { return null; @@ -77,9 +103,15 @@ module.exports = { return null; } + const source = context.getSourceCode(); + const calleeText = source.getText(node); + const expectText = source.getText(expectCall); + const assertionText = calleeText.substring(expectText.length + 1); + context.report({ node, - messageId: 'methodNotInvoked', + // message: `"${assertionText}" used as function` + message: `expect().${assertionText} must be invoked with parentheses, e.g., expect().${assertionText}()`, }); }, }; diff --git a/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js index 671015e4..7cb4dfa9 100644 --- a/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js +++ b/configs/eslint-plugin-devtools/rules/no-expect-method-without-call.test.js @@ -82,14 +82,24 @@ it('should test something', () => { code: 'expect(promise).to.be.rejectedWith(Error)', parserOptions: { ecmaVersion: 2021 }, }, + { + options: [{ properties: ['throw'] }], + code: ` + it("does not fail passed property", function() { + expect(result).to.throw; + }); + `, + }, ], + invalid: [ { code: 'expect(() => someFn()).to.throw', parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.throw must be invoked with parentheses, e.g., expect().to.throw()', }, ], }, @@ -98,16 +108,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', - }, - ], - }, - { - code: 'expect("pineapple").to.include', - parserOptions: { ecmaVersion: 2021 }, - errors: [ - { - messageId: 'methodNotInvoked', + message: + 'expect().to.be.a must be invoked with parentheses, e.g., expect().to.be.a()', }, ], }, @@ -116,7 +118,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.not.include must be invoked with parentheses, e.g., expect().to.not.include()', }, ], }, @@ -125,7 +128,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.throw must be invoked with parentheses, e.g., expect().to.throw()', }, ], }, @@ -134,7 +138,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.not.throw must be invoked with parentheses, e.g., expect().to.not.throw()', }, ], }, @@ -143,7 +148,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.not.throws must be invoked with parentheses, e.g., expect().to.not.throws()', }, ], }, @@ -152,7 +158,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.not.Throw must be invoked with parentheses, e.g., expect().to.not.Throw()', }, ], }, @@ -161,7 +168,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().not.to.throw must be invoked with parentheses, e.g., expect().not.to.throw()', }, ], }, @@ -170,7 +178,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.throw must be invoked with parentheses, e.g., expect().to.throw()', }, ], }, @@ -182,7 +191,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.throw must be invoked with parentheses, e.g., expect().to.throw()', }, ], }, @@ -191,7 +201,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.throw must be invoked with parentheses, e.g., expect().to.throw()', }, ], }, @@ -200,7 +211,8 @@ it('should test something', () => { parserOptions: { ecmaVersion: 2021 }, errors: [ { - messageId: 'methodNotInvoked', + message: + 'expect().to.throw must be invoked with parentheses, e.g., expect().to.throw()', }, ], },