Skip to content

Commit

Permalink
chore: implement ESLint rules for correct CC implementations
Browse files Browse the repository at this point in the history
  • Loading branch information
AlCalzone committed Sep 21, 2023
1 parent 2ac9c45 commit e55fc81
Show file tree
Hide file tree
Showing 5 changed files with 181 additions and 25 deletions.
6 changes: 6 additions & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,5 +182,11 @@ module.exports = {
"@zwave-js/ccapi-validate-args": "error",
},
},
{
files: ["packages/cc/src/**"],
rules: {
"@zwave-js/consistent-cc-classes": "error",
},
},
],
};
2 changes: 2 additions & 0 deletions packages/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { ccAPIValidateArgs } from "./rules/ccapi-validate-args.js";
import { consistentCCClasses } from "./rules/consistent-cc-classes.js";
import { noDebugInTests } from "./rules/no-debug-in-tests.js";

module.exports = {
rules: {
"no-debug-in-tests": noDebugInTests,
"ccapi-validate-args": ccAPIValidateArgs,
"consistent-cc-classes": consistentCCClasses,
},
};
28 changes: 3 additions & 25 deletions packages/eslint-plugin/src/rules/ccapi-validate-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import {
AST_NODE_TYPES,
AST_TOKEN_TYPES,
ESLintUtils,
type TSESTree,
} from "@typescript-eslint/utils";
import { findDecoratorContainingCCId, getCCNameFromDecorator } from "../utils";

const isFixMode = process.argv.some((arg) => arg.startsWith("--fix"));

Expand All @@ -29,32 +29,10 @@ export const ccAPIValidateArgs = ESLintUtils.RuleCreator.withoutDocs({
}
},
ClassDeclaration(node) {
const APIDecorator = node.decorators.find((d) =>
d.expression.type === AST_NODE_TYPES.CallExpression
&& d.expression.callee.type === AST_NODE_TYPES.Identifier
&& d.expression.callee.name === "API"
&& d.expression.arguments.length === 1
&& d.expression.arguments[0].type
=== AST_NODE_TYPES.MemberExpression
&& d.expression.arguments[0].object.type
=== AST_NODE_TYPES.Identifier
&& d.expression.arguments[0].object.name
=== "CommandClasses"
&& (d.expression.arguments[0].property.type
=== AST_NODE_TYPES.Identifier
|| (d.expression.arguments[0].property.type
=== AST_NODE_TYPES.Literal
&& typeof d.expression.arguments[0].property.value
=== "string"))
);
const APIDecorator = findDecoratorContainingCCId(node, ["API"]);
if (!APIDecorator) return;

const prop: TSESTree.Literal | TSESTree.Identifier =
(APIDecorator.expression as any).arguments[0].property;

currentAPIClassCCName = prop.type === AST_NODE_TYPES.Literal
? prop.value as string
: prop.name;
currentAPIClassCCName = getCCNameFromDecorator(APIDecorator);
},
MethodDefinition(node) {
// Only check methods inside a class decorated with @API
Expand Down
120 changes: 120 additions & 0 deletions packages/eslint-plugin/src/rules/consistent-cc-classes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { AST_NODE_TYPES, ESLintUtils } from "@typescript-eslint/utils";
import {
findDecorator,
findDecoratorContainingCCId,
getCCNameFromDecorator,
} from "../utils";

export const consistentCCClasses = ESLintUtils.RuleCreator.withoutDocs({
create(context) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
let currentCCName: string | undefined;

return {
ClassDeclaration(node) {
// Only look at class declarations ending with "CC"
if (!node.id?.name.endsWith("CC")) return;
// Except InvalidCC, which is a special case
if (node.id?.name === "InvalidCC") return;

// These must...

// ...be in a file that ends with "CC.ts"
if (!context.getFilename().endsWith("CC.ts")) {
context.report({
node,
loc: node.id.loc,
messageId: "wrong-filename",
});
}

// ...have an @commandClass decorator
const ccDecorator = findDecoratorContainingCCId(node, [
"commandClass",
]);
if (!ccDecorator) {
context.report({
node,
loc: node.id.loc,
messageId: "missing-cc-decorator",
});
} else {
currentCCName = getCCNameFromDecorator(ccDecorator);
}

// ...have a @implementedVersion decorator
const versionDecorator = findDecorator(
node,
"implementedVersion",
);
if (!versionDecorator) {
context.report({
node,
loc: node.id.loc,
messageId: "missing-version-decorator",
});
}

// ...be exported
if (
node.parent.type !== AST_NODE_TYPES.ExportNamedDeclaration
|| node.parent.exportKind !== "value"
) {
context.report({
node,
loc: node.id.loc,
messageId: "must-export",
fix: (fixer) => fixer.insertTextBefore(node, "export "),
});
}

// ...inherit from CommandClass
if (
!node.superClass
|| node.superClass.type !== AST_NODE_TYPES.Identifier
|| node.superClass.name !== "CommandClass"
) {
context.report({
node,
loc: node.id.loc,
fix: (fixer) =>
node.superClass
? fixer.replaceText(
node.superClass,
"CommandClass",
)
: fixer.insertTextAfter(
node.id!,
" extends CommandClass",
),
messageId: "must-inherit-commandclass",
});
}
},
"ClassDeclaration:exit"(_node) {
currentCCName = undefined;
},
};
},
meta: {
docs: {
description:
"Ensures that CC implementations follow certain conventions.",
},
type: "problem",
schema: [],
fixable: "code",
messages: {
"wrong-filename":
"Classes that end with `CC` are considered CC implementations and MUST be in a file whose name ends with `CC.ts`",
"missing-cc-decorator":
"Classes implementing a CC must have a CC assigned using the `@commandClass(...)` decorator",
"missing-version-decorator":
"Classes implementing a CC must be decorated with `@implementedVersion(...)`",
"must-export": "Classes implementing a CC must be exported",
"must-inherit-commandclass":
"Classes implementing a CC MUST inherit from `CommandClass`",
},
},
defaultOptions: [],
});
50 changes: 50 additions & 0 deletions packages/eslint-plugin/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,55 @@
import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
import path from "node:path";

export const repoRoot = path.normalize(
__dirname.slice(0, __dirname.lastIndexOf(`${path.sep}packages${path.sep}`)),
);

/** Finds a specific decorator on a node */
export function findDecorator(
node: TSESTree.ClassDeclaration,
name: string,
): TSESTree.Decorator | undefined {
return node.decorators.find((d) =>
d.expression.type === AST_NODE_TYPES.CallExpression
&& d.expression.callee.type === AST_NODE_TYPES.Identifier
&& d.expression.callee.name === name
);
}

/** Finds the `@API(...)` or `@commandClass(...)` decorator on a node */
export function findDecoratorContainingCCId(
node: TSESTree.ClassDeclaration,
possibleNames: string[] = ["API", "commandClass"],
): TSESTree.Decorator | undefined {
return node.decorators.find((d) =>
d.expression.type === AST_NODE_TYPES.CallExpression
&& d.expression.callee.type === AST_NODE_TYPES.Identifier
&& possibleNames.includes(d.expression.callee.name)
&& d.expression.arguments.length === 1
&& d.expression.arguments[0].type
=== AST_NODE_TYPES.MemberExpression
&& d.expression.arguments[0].object.type
=== AST_NODE_TYPES.Identifier
&& d.expression.arguments[0].object.name
=== "CommandClasses"
&& (d.expression.arguments[0].property.type
=== AST_NODE_TYPES.Identifier
|| (d.expression.arguments[0].property.type
=== AST_NODE_TYPES.Literal
&& typeof d.expression.arguments[0].property.value
=== "string"))
);
}

/** Takes a decorator found using {@link findDecoratorContainingCCId} and returns the CC name */
export function getCCNameFromDecorator(
decorator: TSESTree.Decorator,
): string {
const prop: TSESTree.Literal | TSESTree.Identifier =
(decorator.expression as any).arguments[0].property;

return prop.type === AST_NODE_TYPES.Literal
? prop.value as string
: prop.name;
}

0 comments on commit e55fc81

Please sign in to comment.