diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d22fe0..f16d9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add rule GCI2001 "Avoid polling without checking page visibility" - [#84](https://github.com/green-code-initiative/creedengo-javascript/pull/84) Add rule GCI535 "No imported number format library" ## [3.1.0] - 2026-05-10 diff --git a/eslint-plugin/lib/rules/no-polling-without-visibility-check.js b/eslint-plugin/lib/rules/no-polling-without-visibility-check.js new file mode 100644 index 0000000..836069c --- /dev/null +++ b/eslint-plugin/lib/rules/no-polling-without-visibility-check.js @@ -0,0 +1,120 @@ +/* + * creedengo JavaScript plugin - Provides rules to reduce the environmental footprint of your JavaScript programs + * Copyright © 2023 Green Code Initiative (https://green-code-initiative.org) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +"use strict"; + +/** @type {import("eslint").Rule.RuleModule} */ +module.exports = { + meta: { + type: "suggestion", + docs: { + description: "Avoid polling without checking page visibility", + category: "eco-design", + recommended: "warn", + }, + messages: { + AvoidPollingWithoutVisibilityCheck: + "Add a visibilitychange event listener to pause polling when the page is hidden.", + }, + schema: [], + }, + create: function (context) { + /** @type {import("eslint").Rule.Node[]} */ + const pollingCalls = []; + let hasVisibilityChangeListener = false; + + /** + * Stack of enclosing named function names (null for anonymous functions). + * Used to detect recursive setTimeout patterns. + * @type {(string|null)[]} + */ + const functionNameStack = []; + + function isVisibilityChangeListener(node) { + return ( + node.callee.type === "MemberExpression" && + node.callee.property.name === "addEventListener" && + node.arguments.length >= 1 && + node.arguments[0].type === "Literal" && + node.arguments[0].value === "visibilitychange" + ); + } + + function isSetInterval(node) { + return ( + node.callee.type === "Identifier" && + node.callee.name === "setInterval" + ); + } + + /** + * Detects `setTimeout(fnName, delay)` where `fnName` is an enclosing + * function — i.e. a recursive polling pattern. + */ + function isRecursiveSetTimeout(node) { + if ( + node.callee.type !== "Identifier" || + node.callee.name !== "setTimeout" || + node.arguments.length < 1 + ) { + return false; + } + const firstArg = node.arguments[0]; + return ( + firstArg.type === "Identifier" && + functionNameStack.includes(firstArg.name) + ); + } + + return { + FunctionDeclaration(node) { + functionNameStack.push(node.id ? node.id.name : null); + }, + "FunctionDeclaration:exit"() { + functionNameStack.pop(); + }, + FunctionExpression(node) { + functionNameStack.push(node.id ? node.id.name : null); + }, + "FunctionExpression:exit"() { + functionNameStack.pop(); + }, + + CallExpression(node) { + if (isVisibilityChangeListener(node)) { + hasVisibilityChangeListener = true; + return; + } + if (isSetInterval(node) || isRecursiveSetTimeout(node)) { + pollingCalls.push(node); + } + }, + + "Program:exit"() { + if (!hasVisibilityChangeListener) { + for (const node of pollingCalls) { + context.report({ + node, + messageId: "AvoidPollingWithoutVisibilityCheck", + }); + } + } + }, + }; + }, +}; diff --git a/eslint-plugin/tests/lib/rules/no-polling-without-visibility-check.test.js b/eslint-plugin/tests/lib/rules/no-polling-without-visibility-check.test.js new file mode 100644 index 0000000..9bc1c17 --- /dev/null +++ b/eslint-plugin/tests/lib/rules/no-polling-without-visibility-check.test.js @@ -0,0 +1,139 @@ +/* + * creedengo JavaScript plugin - Provides rules to reduce the environmental footprint of your JavaScript programs + * Copyright © 2023 Green Code Initiative (https://green-code-initiative.org) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +"use strict"; + +//------------------------------------------------------------------------------ +// Requirements +//------------------------------------------------------------------------------ + +const rule = require("../../../lib/rules/no-polling-without-visibility-check"); +const { RuleTester } = require("eslint"); +const { describe, it } = require("node:test"); + +//------------------------------------------------------------------------------ +// Tests +//------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + }, +}); + +const expectedError = { + messageId: "AvoidPollingWithoutVisibilityCheck", +}; + +const tests = { + valid: [ + // setInterval with visibilitychange listener + ` + let intervalId; + + function startPolling() { + intervalId = setInterval(() => fetchData(), 5000); + } + + function stopPolling() { + clearInterval(intervalId); + } + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + startPolling(); + } else { + stopPolling(); + } + }); + + startPolling(); + `, + + // Recursive setTimeout with visibilitychange listener + ` + let timeoutId; + + function poll() { + fetchData(); + timeoutId = setTimeout(poll, 5000); + } + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + poll(); + } else { + clearTimeout(timeoutId); + } + }); + + poll(); + `, + + // setTimeout that is NOT recursive — should not be flagged + ` + setTimeout(() => doSomethingOnce(), 1000); + `, + + // No polling at all + ` + function fetchOnce() { + fetch('/api/data').then(r => r.json()).then(console.log); + } + fetchOnce(); + `, + ], + + invalid: [ + // setInterval without visibilitychange listener + { + code: ` + setInterval(() => fetchData(), 5000); + `, + errors: [expectedError], + }, + + // Recursive setTimeout without visibilitychange listener + { + code: ` + function poll() { + fetchData(); + setTimeout(poll, 5000); + } + poll(); + `, + errors: [expectedError], + }, + + // Multiple setInterval calls — each should be reported + { + code: ` + setInterval(() => syncData(), 3000); + setInterval(() => refreshUI(), 10000); + `, + errors: [expectedError, expectedError], + }, + ], +}; + +describe("no-polling-without-visibility-check", () => { + it("no-polling-without-visibility-check", () => { + ruleTester.run("no-polling-without-visibility-check", rule, tests); + }); +}); diff --git a/sonar-plugin/pom.xml b/sonar-plugin/pom.xml index 712eec7..9205d29 100644 --- a/sonar-plugin/pom.xml +++ b/sonar-plugin/pom.xml @@ -48,7 +48,7 @@ ${encoding} ${encoding} - 3.0.0 + feature-new-rule-no-polling-without-visibility-check-SNAPSHOT 13.0.0.3026 11.8.0.37897 1.25.1.3002 diff --git a/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java index 820272a..506167f 100644 --- a/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java +++ b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/CheckList.java @@ -45,6 +45,7 @@ public static List> getAllHooks() { NoImportedNumberFormatLibrary.class, NoMultipleAccessDomElement.class, NoMultipleStyleChanges.class, + NoPollingWithoutVisibilityCheck.class, NoTorch.class, PreferCollectionsWithPagination.class, PreferLighterFormatsForImageFiles.class, diff --git a/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/checks/NoPollingWithoutVisibilityCheck.java b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/checks/NoPollingWithoutVisibilityCheck.java new file mode 100644 index 0000000..8a68c04 --- /dev/null +++ b/sonar-plugin/src/main/java/org/greencodeinitiative/creedengo/javascript/checks/NoPollingWithoutVisibilityCheck.java @@ -0,0 +1,37 @@ +/* + * Creedengo JavaScript plugin - Provides rules to reduce the environmental footprint of your JavaScript programs + * Copyright © 2023 Green Code Initiative (https://green-code-initiative.org) + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.greencodeinitiative.creedengo.javascript.checks; + +import org.sonar.check.Rule; +import org.sonar.plugins.javascript.api.EslintHook; +import org.sonar.plugins.javascript.api.JavaScriptRule; +import org.sonar.plugins.javascript.api.TypeScriptRule; + +@JavaScriptRule +@TypeScriptRule +@Rule(key = NoPollingWithoutVisibilityCheck.RULE_KEY) +public class NoPollingWithoutVisibilityCheck implements EslintHook { + + public static final String RULE_KEY = "GCI2001"; + + @Override + public String eslintKey() { + return "@creedengo/no-polling-without-visibility-check"; + } + +} diff --git a/test-project/sonar-project.properties b/test-project/sonar-project.properties index 764219c..58c877b 100644 --- a/test-project/sonar-project.properties +++ b/test-project/sonar-project.properties @@ -1,3 +1,5 @@ sonar.exclusions=node_modules sonar.sources=src sonar.projectKey=creedengo-javascript-test-project +sonar.host.url=http://localhost:9000 +sonar.token=sqa_31a11e64c69be98c50572f3bcdb3811f0255dfcd diff --git a/test-project/src/no-polling-without-visibility-check.js b/test-project/src/no-polling-without-visibility-check.js new file mode 100644 index 0000000..c0a8368 --- /dev/null +++ b/test-project/src/no-polling-without-visibility-check.js @@ -0,0 +1,19 @@ +// Non compliant: setInterval without visibilitychange listener +setInterval(() => { + fetch("/api/data").then((res) => res.json()).then(console.log); +}, 3000); + +// Non compliant: second setInterval without visibilitychange listener +setInterval(() => { + console.log("Ping server..."); +}, 5000); + +// Non compliant: recursive setTimeout without visibilitychange listener +function poll() { + fetch("/api/status") + .then((res) => res.json()) + .then((data) => console.log(data)); + setTimeout(poll, 4000); +} + +poll(); diff --git a/test-project/yarn.lock b/test-project/yarn.lock index 4cd76f6..1a956da 100644 --- a/test-project/yarn.lock +++ b/test-project/yarn.lock @@ -21,10 +21,10 @@ __metadata: "@creedengo/eslint-plugin@file:../eslint-plugin::locator=creedengo-javascript-test-project%40workspace%3A.": version: 3.1.0 - resolution: "@creedengo/eslint-plugin@file:../eslint-plugin#../eslint-plugin::hash=30e64e&locator=creedengo-javascript-test-project%40workspace%3A." + resolution: "@creedengo/eslint-plugin@file:../eslint-plugin#../eslint-plugin::hash=a3cc81&locator=creedengo-javascript-test-project%40workspace%3A." peerDependencies: eslint: ^9.0.0 || ^10.0.0 - checksum: 10c0/35357906578cb26b758f44fb8fdb12656de3a6d3297d97002f3a1c755066b81e1321badd1f4a01a9e9d156b0545b5611774c2ed83e80600168c134f0dc0846fd + checksum: 10c0/672076df8ce6a5d9a44bde94b519c8a00eb3082c7e5ea4cc6384fefec7c8d4c8f697dcb5ec58e0b603f4b3f485e4e3b123db711c06cd2957d0853263f0442c1d languageName: node linkType: hard