diff --git a/packages/config/config/.eslintrc.js b/packages/config/config/.eslintrc.js index 83e96d99f2dd..6b11511791cf 100644 --- a/packages/config/config/.eslintrc.js +++ b/packages/config/config/.eslintrc.js @@ -10,5 +10,6 @@ module.exports = { ], rules: { "@zwave-js/consistent-device-config-property-order": "error", + "@zwave-js/no-unnecessary-min-max-value": "error" } }; diff --git a/packages/eslint-plugin/src/index.ts b/packages/eslint-plugin/src/index.ts index c99b2cb340d2..1447d08b119b 100644 --- a/packages/eslint-plugin/src/index.ts +++ b/packages/eslint-plugin/src/index.ts @@ -3,6 +3,7 @@ import { consistentCCClasses } from "./rules/consistent-cc-classes.js"; import { consistentDeviceConfigPropertyOrder } from "./rules/consistent-device-config-property-order.js"; import { noDebugInTests } from "./rules/no-debug-in-tests.js"; import { noForbiddenImports } from "./rules/no-forbidden-imports.js"; +import { noUnnecessaryMinMaxValue } from "./rules/no-unnecessary-min-max-value.js"; module.exports = { rules: { @@ -12,5 +13,6 @@ module.exports = { "no-forbidden-imports": noForbiddenImports, "consistent-device-config-property-order": consistentDeviceConfigPropertyOrder, + "no-unnecessary-min-max-value": noUnnecessaryMinMaxValue, }, }; diff --git a/packages/eslint-plugin/src/rules/consistent-device-config-property-order.ts b/packages/eslint-plugin/src/rules/consistent-device-config-property-order.ts index 5825dcff56d9..bf616135e5d7 100644 --- a/packages/eslint-plugin/src/rules/consistent-device-config-property-order.ts +++ b/packages/eslint-plugin/src/rules/consistent-device-config-property-order.ts @@ -172,7 +172,8 @@ export const consistentDeviceConfigPropertyOrder: JSONCRule.RuleModule = { }, meta: { docs: { - description: "foo", + description: + "Ensures consistent ordering of properties in configuration parameter definitions", }, fixable: "code", schema: [], diff --git a/packages/eslint-plugin/src/rules/no-unnecessary-min-max-value.ts b/packages/eslint-plugin/src/rules/no-unnecessary-min-max-value.ts new file mode 100644 index 000000000000..4d373e54031d --- /dev/null +++ b/packages/eslint-plugin/src/rules/no-unnecessary-min-max-value.ts @@ -0,0 +1,77 @@ +import type { AST } from "jsonc-eslint-parser"; +import { type JSONCRule, removeJSONProperty } from "../utils"; + +export const noUnnecessaryMinMaxValue: JSONCRule.RuleModule = { + create(context) { + if (!context.parserServices.isJSON) { + return {}; + } + return { + // Avoid unnecessary min/max value in parameters with predefined options + "JSONProperty[key.value='paramInformation'] > JSONArrayExpression > JSONObjectExpression"( + node: AST.JSONObjectExpression, + ) { + // Imports can make it necessary to override min/maxValue + const hasImport = node.properties.some((p) => + p.key.type === "JSONLiteral" + && p.key.value === "$import" + ); + if (hasImport) return; + + const allowManualEntryFalse = node.properties.some((p) => + p.key.type === "JSONLiteral" + && p.key.value === "allowManualEntry" + && p.value.type === "JSONLiteral" + && p.value.value === false + ); + if (!allowManualEntryFalse) return; + + const hasOptions = node.properties.some((p) => + p.key.type === "JSONLiteral" + && p.key.value === "options" + && p.value.type === "JSONArrayExpression" + && p.value.elements.length > 0 + ); + if (!hasOptions) return; + + const minValue = node.properties.find((p) => + p.key.type === "JSONLiteral" + && p.key.value === "minValue" + ); + if (minValue) { + context.report({ + loc: minValue.loc, + messageId: "no-min-value", + fix: removeJSONProperty(context, minValue), + }); + } + + const maxValue = node.properties.find((p) => + p.key.type === "JSONLiteral" + && p.key.value === "maxValue" + ); + if (maxValue) { + context.report({ + loc: maxValue.loc, + messageId: "no-max-value", + fix: removeJSONProperty(context, maxValue), + }); + } + }, + }; + }, + meta: { + docs: { + description: "Ensures that min/maxValue are not used unnecessarily", + }, + fixable: "code", + schema: [], + messages: { + "no-min-value": + `For parameters with "allowManualEntry = false" and predefined options, "minValue" is unnecessary and should not be specified.`, + "no-max-value": + `For parameters with "allowManualEntry = false" and predefined options, "maxValue" is unnecessary and should not be specified.`, + }, + type: "problem", + }, +}; diff --git a/packages/eslint-plugin/src/utils.ts b/packages/eslint-plugin/src/utils.ts index 0ba866fba827..577c91ce81e9 100644 --- a/packages/eslint-plugin/src/utils.ts +++ b/packages/eslint-plugin/src/utils.ts @@ -5,6 +5,7 @@ import { } from "@typescript-eslint/utils"; import { CommandClasses } from "@zwave-js/core"; import { type Rule as ESLintRule } from "eslint"; +import { type AST as JSONC_AST } from "jsonc-eslint-parser"; import path from "node:path"; export const repoRoot = path.normalize( @@ -110,3 +111,56 @@ export namespace JSONCRule { schema?: ESLintRule.RuleMetaData["schema"]; } } + +export function removeJSONProperty( + context: ESLintRule.RuleContext, + property: JSONC_AST.JSONProperty, +): (fixer: ESLintRule.RuleFixer) => ESLintRule.Fix { + const propIndex = property.parent.properties.indexOf(property); + const prevProp = property.parent.properties[propIndex - 1]; + const nextProp = property.parent.properties[propIndex + 1]; + + let leadingComments = context.sourceCode.getCommentsBefore(property as any); + if (prevProp) { + // Omit leading comments that are actually trailing comments of the previous property + leadingComments = leadingComments.filter((c) => + c.loc?.start.line !== prevProp.loc.end.line + ); + } + + // Remove from the beginning of the first actual leading comment... + const actualStart = Math.min( + property.range[0], + ...leadingComments.map((c) => c.range![0]), + ); + + let actualEnd = property.range[1]; + if (nextProp) { + // ...to either the first actual leading comment of the next property... + const nextPropLeadingComments = context.sourceCode.getCommentsBefore( + nextProp as any, + ).filter((c) => c.loc?.start.line !== property.loc.end.line); + actualEnd = Math.max( + actualEnd, + Math.min( + nextProp.range[0], + ...nextPropLeadingComments.map((c) => c.range![0]), + ), + ); + } else { + // ...or the end of the last trailing comment of this property + const trailingComments = context.sourceCode.getCommentsAfter( + property as any, + ); + actualEnd = Math.max( + actualEnd, + ...trailingComments.map((c) => c.range![1]), + ); + } + + return (fixer) => + fixer.removeRange([ + actualStart, + actualEnd, + ]); +}