UNPKG

homebridge-plugin-utils

Version:

Opinionated utilities to provide common capabilities and create rich configuration webUI experiences for Homebridge plugins.

512 lines (377 loc) 14.9 kB
/* Copyright(C) 2017-2026, HJD (https://github.com/hjdhjd). All rights reserved. * * eslint-rules.mjs: Opinionated default linting rules for Homebridge plugins. */ import eslintJs from "@eslint/js"; import stylistic from "@stylistic/eslint-plugin"; import ts from "typescript-eslint"; const ruleBlankAfterOpenBrace = { create(context) { function checkNode(node) { const sourceCode = context.sourceCode; const openBrace = sourceCode.getFirstToken(node); const nextToken = sourceCode.getTokenAfter(openBrace); if(openBrace.loc.end.line < nextToken.loc.start.line) { const nextLine = sourceCode.lines[openBrace.loc.end.line]; if(nextLine.trim() !== "") { context.report({ fix(fixer) { return fixer.insertTextAfter(openBrace, "\n\n"); }, loc: openBrace.loc, message: "Expected blank line after left brace and newline.", node }); } } } return { // Validate block statements. BlockStatement(node) { checkNode(node); }, // Validate class declarations and any embedded methods or objects. ClassBody(node) { checkNode(node); for(const element of node.body) { if((![ "MethodDefinition", "PropertyDefinition" ].includes(element.type)) || (element.value?.type !== "ObjectExpression")) { continue; } checkNode(node); } }, // Validate object expressions. ObjectExpression(node) { checkNode(node); }, // Validate interface declarations and any embedded objects. TSInterfaceBody(node) { checkNode(node); for(const property of node.body) { if(!property.typeAnnotation || (property.type !== "TSPropertySignature") || (property.typeAnnotation.typeAnnotation.type !== "TSTypeLiteral")) { continue; } checkNode(property.typeAnnotation.typeAnnotation); } }, // Validate type declarations and any embedded objects. TSTypeAliasDeclaration(node) { if(node.typeAnnotation.type !== "TSTypeLiteral") { return; } checkNode(node.typeAnnotation); for(const member of node.typeAnnotation.members) { if(!member.typeAnnotation || (member.type !== "TSPropertySignature") || (member.typeAnnotation.typeAnnotation.type !== "TSTypeLiteral")) { continue; } checkNode(member.typeAnnotation.typeAnnotation); } } }; }, meta: { docs: { category: "Stylistic Issues", description: "require a blank line after an opening brace if it is immediately followed by a newline", recommended: false }, fixable: "whitespace", schema: [], type: "layout" } }; const ruleParenComparisonsInLogical = { create(context) { const sourceCode = context.sourceCode; // Comparison operators to wrap when used inside && / || compounds. const comparisonOperators = new Set([ "==", "!=", "===", "!==", "<", "<=", ">", ">=", "in", "instanceof" ]); const logicalOperators = new Set([ "&&", "||" ]); function isComparison(node) { return !!node && (node.type === "BinaryExpression") && comparisonOperators.has(node.operator); } function isParenthesized(node) { // Prefer ESLint's helper when available. if(typeof sourceCode.isParenthesized === "function") { return sourceCode.isParenthesized(node); } // Fallback token check. const before = sourceCode.getTokenBefore(node); const after = sourceCode.getTokenAfter(node); return !!before && !!after && (before.value === "(") && (after.value === ")"); } function checkOperand(node) { if(!isComparison(node) || isParenthesized(node)) { return; } context.report({ fix(fixer) { return fixer.replaceText(node, "(" + sourceCode.getText(node) + ")"); }, message: "Wrap comparison operands in parentheses inside compound logical expressions (&&, ||).", node }); } return { LogicalExpression(node) { if(!logicalOperators.has(node.operator)) { return; } // Only wrap direct operands of && / || when they are comparisons. checkOperand(node.left); checkOperand(node.right); } }; }, meta: { docs: { category: "Stylistic Issues", description: "require parentheses around comparison operands when used inside compound logical expressions (&&, ||)", recommended: false }, fixable: "code", schema: [], type: "layout" } }; // Enforce no space between `catch` and `(` when a parameter is present. Parameterless catch clauses (e.g., `catch {`) are ignored and handled by space-before-blocks. const ruleCatchParenSpacing = { create(context) { const sourceCode = context.sourceCode; return { CatchClause(node) { // Only check catch clauses that have a parameter. if(!node.param) { return; } // Get the `catch` keyword token and the opening parenthesis token. const catchKeyword = sourceCode.getFirstToken(node); const openParen = sourceCode.getTokenAfter(catchKeyword); // Check if there is whitespace between `catch` and `(`. if((openParen.value === "(") && (catchKeyword.range[1] < openParen.range[0])) { context.report({ fix(fixer) { return fixer.removeRange([ catchKeyword.range[1], openParen.range[0] ]); }, loc: { end: openParen.loc.start, start: catchKeyword.loc.end }, message: "Unexpected space between `catch` and `(`.", node }); } } }; }, meta: { docs: { category: "Stylistic Issues", description: "disallow space between `catch` and `(` when a parameter is present", recommended: false }, fixable: "whitespace", schema: [], type: "layout" } }; // Extract rules by name from a typescript-eslint config array. Throws if the named entry is missing so that a typescript-eslint version bump that renames config entries // fails loudly rather than silently dropping rules. function configRules(configs, name) { const entry = configs.find((c) => c.name === name); if(!entry) { throw new Error("typescript-eslint config entry not found: " + name); } return entry.rules; } // ESlint plugins to use. const plugins = { "@hjdhjd": { "rules": { "blank-line-after-open-brace": ruleBlankAfterOpenBrace, "catch-paren-spacing": ruleCatchParenSpacing, "paren-comparisons-in-logical": ruleParenComparisonsInLogical } }, "@stylistic": stylistic, "@typescript-eslint": ts.plugin }; // TypeScript-specific rules. const tsRules = { ...configRules(ts.configs.strictTypeChecked, "typescript-eslint/strict-type-checked"), ...configRules(ts.configs.stylisticTypeChecked, "typescript-eslint/stylistic-type-checked"), "@stylistic/member-delimiter-style": "warn", "@typescript-eslint/await-thenable": "warn", "@typescript-eslint/consistent-type-imports": "warn", "@typescript-eslint/explicit-function-return-type": "warn", "@typescript-eslint/explicit-module-boundary-types": "warn", "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-floating-promises": [ "warn", { "ignoreIIFE": true } ], "@typescript-eslint/no-non-null-assertion": "warn", "@typescript-eslint/no-unnecessary-condition": "warn", "@typescript-eslint/no-unused-expressions": "warn", "@typescript-eslint/no-unused-vars": [ "warn", { "caughtErrors": "none" } ], "@typescript-eslint/prefer-nullish-coalescing": "warn", "@typescript-eslint/promise-function-async": "warn", "require-await": "off", // eslint-disable-next-line sort-keys "@typescript-eslint/require-await": "warn", "no-dupe-class-members": "off", "no-redeclare": "off", "no-undef": "off", "no-unused-expressions": "off", "no-unused-vars": "off" }; // JavaScript-specific rules. const jsRules = { ...ts.configs.disableTypeChecked.rules, "@typescript-eslint/no-floating-promises": "off", "require-await": "warn" }; // Rules that exist across both JavaScript and TypeScript files. const commonRules = { ...ts.configs.eslintRecommended.rules, "@hjdhjd/blank-line-after-open-brace": "warn", "@hjdhjd/catch-paren-spacing": "warn", "@hjdhjd/paren-comparisons-in-logical": "warn", "@stylistic/array-bracket-spacing": [ "warn", "always", { "arraysInArrays": true, "objectsInArrays": true, "singleValue": false } ], "@stylistic/block-spacing": "warn", "@stylistic/brace-style": [ "warn", "1tbs", { "allowSingleLine": true } ], "@stylistic/comma-dangle": "warn", "@stylistic/eol-last": [ "warn", "always" ], "@stylistic/generator-star-spacing": "warn", "@stylistic/implicit-arrow-linebreak": "warn", "@stylistic/indent": [ "warn", 2, { "SwitchCase": 1 } ], "@stylistic/keyword-spacing": [ "warn", { "overrides": { "for": { "after": false }, "if": { "after": false }, "switch": { "after": false}, "while": { "after": false } } } ], "@stylistic/linebreak-style": [ "warn", "unix" ], "@stylistic/lines-between-class-members": [ "warn", "always", { "exceptAfterSingleLine": true } ], "@stylistic/max-len": [ "warn", 170 ], "@stylistic/no-tabs": "warn", "@stylistic/no-trailing-spaces": "warn", "@stylistic/operator-linebreak": [ "warn", "after", { "overrides": { ":": "after", "?": "after" } } ], "@stylistic/padding-line-between-statements": [ "warn", // Require a blank line before every statement type in next. { "blankLine": "always", "next": [ "break", "case", "class", "continue", "default", "export", "for", "function", "if", "import", "return" ], "prev": "*" }, // Require blank lines after every statement type in prev. { "blankLine": "always", "next": "*", "prev": [ "const", "directive", "let", "var" ] }, // Multiple sequential case declarations may be grouped together. { "blankLine": "any", "next": [ "case", "default" ], "prev": [ "case", "default" ] }, // Multiple sequential variable declarations may be grouped together. { "blankLine": "any", "next": [ "const", "let", "var" ], "prev": [ "const", "let", "var" ] }, // Multiple sequential export declarations may be grouped together. { "blankLine": "any", "next": "export", "prev": "export" }, // Multiple sequential import declarations must be grouped together. { "blankLine": "never", "next": "import", "prev": "import" }, // Multiple sequential directive prologues must be grouped together. { "blankLine": "never", "next": "directive", "prev": "directive" } ], "@stylistic/semi": [ "warn", "always" ], "@stylistic/space-before-function-paren": [ "warn", { "anonymous": "never", "asyncArrow": "always", "catch": "never", "named": "never" } ], "@stylistic/space-in-parens": "warn", "@stylistic/space-infix-ops": "warn", "@stylistic/space-unary-ops": "warn", "@typescript-eslint/no-this-alias": "warn", "camelcase": "warn", "curly": [ "warn", "all" ], "dot-notation": "warn", "eqeqeq": "warn", "logical-assignment-operators": [ "warn", "always", { "enforceForIfStatements": true } ], "no-await-in-loop": "warn", "no-console": "warn", "no-restricted-syntax": [ "warn", "TemplateLiteral" ], "prefer-arrow-callback": "warn", "prefer-const": "warn", "quotes": [ "warn", "double", { "allowTemplateLiterals": false, "avoidEscape": false } ], "sort-imports": "warn", "sort-keys": "warn", "sort-vars": "warn" }; // Globals that tend to exist in Homebridge plugins with a webUI. const globalsUi = Object.fromEntries([ "clearTimeout", "console", "container", "document", "fetch", "getComputedStyle", "homebridge", "setTimeout", "window" ] .map(key => [ key, "readonly" ])); /* Build a ready-to-use ESLint flat config array. Consumers call this function and export default the result. * * @param {object} options - Configuration options. * @param {string[]} options.ts - Glob patterns for TypeScript files (strict + stylistic type-checked rules). * @param {string[]} options.js - Glob patterns for JavaScript files (disable-type-checked rules). * @param {string[]} options.ui - Glob patterns for browser UI files (adds browser globals). * @param {string[]} options.allowDefaultProject - Globs passed to parserOptions.projectService.allowDefaultProject. * @param {string[]} options.ignores - Global ignore patterns (in addition to the hardcoded "dist" ignore on the common block). * @param {object[]} options.extraConfigs - Additional flat config objects appended to the output. * @returns {object[]} A flat config array suitable for `export default` in eslint.config.mjs. Block ordering is significant — do not reorder. */ function config({ allowDefaultProject = [], extraConfigs = [], ignores = [], js = [], ts: tsFiles = [], ui = [] } = {}) { const allFiles = [ ...tsFiles, ...js, ...ui ]; const configs = []; // Global ignores block. if(ignores.length > 0) { configs.push({ ignores }); } // Core ESLint recommended rules. This block must precede the TS/JS blocks so that their rule overrides (e.g., no-unused-vars: "off") take priority. if(allFiles.length > 0) { configs.push(eslintJs.configs.recommended); } // TypeScript-specific rules. if(tsFiles.length > 0) { configs.push({ files: tsFiles, rules: { ...tsRules } }); } // JavaScript-specific rules. if(js.length > 0) { configs.push({ files: js, rules: { ...jsRules } }); } // Common rules applied to all linted files. if(allFiles.length > 0) { configs.push({ files: allFiles, ignores: ["dist"], languageOptions: { ecmaVersion: "latest", parser: ts.parser, parserOptions: { ecmaVersion: "latest", projectService: { allowDefaultProject, defaultProject: "./tsconfig.json" } }, sourceType: "module" }, linterOptions: { reportUnusedDisableDirectives: "error" }, plugins: { ...plugins }, rules: { ...commonRules } }); } // UI globals block. if(ui.length > 0) { configs.push({ files: ui, languageOptions: { globals: { ...globalsUi } } }); } // Escape hatch for project-specific config blocks. configs.push(...extraConfigs); return configs; } // Default export is the config function. export default config; // Named exports for advanced customization or gradual migration. export { commonRules, config, globalsUi, jsRules, plugins, tsRules };