Creating rules for code
Implement a linting rule to prevent the code we just transformed from being reintroduced in the future
The Flash codebase now uses Button
components. To get to this point, we first performed an audit of the codebase to understand how button
elements were being used today. Then, we created a Button
component that was capable of handling all those use cases. Finally, we performed a codemod to transform all of the button
elements to the Button
component.
This completes the original refactor described in Module 3. However, an actual codebase will continue to evolve and have further changes made. How do we ensure the codebase stays up-to-date using the Button
component where appropriate?
A common approach for this is to create a rule for that codebase that enforces this. Any time a change is made, the codebase can be checked against that rule. If there are any violations, the change can be blocked.
For example, in a git workflow, a change is made in a branch off the main branch. Before that branch can be merged, this rule (in addition to other things like tests) need to pass. This way, the main branch should never violate this rule.
This concept of applying rules to code is commonly referred to as linting.
Creating a rule#
Let's start by creating a new linter directory and adding the necessary dependencies.
# Create a new directory for the linter script
mkdir linter
# Change into the new linter directory
cd linter
# Initialize a new package.json file
npm init -y
# Add the necessary dependencies
npm i @babel/parser @babel/traverse @babel/code-frame glob ts-node typescript
npm i -D @types/babel__traverse @types/babel__code-frame @types/glob
Then, create a new rule.ts
file with much of the same initial setup as the previous custom audit script.
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import * as fs from "fs";
import * as glob from "glob";
const files = glob.sync("../flash-cards/src/components/**/*.js");
files.forEach((file) => {
const contents = fs.readFileSync(file).toString();
const ast = parse(contents, {
sourceType: "module",
plugins: ["jsx"],
});
traverse(ast, {
JSXOpeningElement({ node }) {
if (node.name.type === "JSXIdentifier" && node.name.name === "button") {
// Find the JSXAttribute (prop) with a name of
// `className` where it's value is a space separated
// string of class names where one of the class
// names is `button`.
const hasButtonClassName = node.attributes.find((attribute) => {
return (
attribute.type === "JSXAttribute" &&
attribute.name.type === "JSXIdentifier" &&
attribute.name.name === "className" &&
attribute.value.type === "StringLiteral" &&
attribute.value.value.split(" ").includes("button")
);
});
// Ignore any button elements that don't also have
// the button class name
if (!hasButtonClassName) return;
// TODO: lint...
}
},
});
});
Since all the nodes that matched these criteria were transformed in the previous module, no nodes will match these criteria in the codebase.
To test this, a new violation can intentionally be added to verify it works as expected. Update the LandingPage
component in the Flash codebase (or any other component) with the following snippet anywhere in the component.
<button className="button">
Hello World!
</button>
Now there is a violation, the linting rule can be updated to log the file and line that violates this rule, to make it easy to find and fix. Each node contains a loc
property that is an object with information about the starting and ending lines and the column numbers.
// Example value of the `loc` property on a node:
{
"start": { "line": 23, "column": 4 },
"end": { "line": 23, "column": 31 }
}
When using AST Explorer, make sure Hide location data is disabled for this module. It will otherwise hide all of the loc
properties from the tree view.
This location information can be appended to the filename to make it easier to jump directly to the position in the code that the violation occurs.
JSXOpeningElement({ node }) {
if (node.name.type === "JSXIdentifier" && node.name.name === "button") {
const hasButtonClassName = node.attributes.find((attribute) => {
return (
attribute.type === "JSXAttribute" &&
attribute.name.type === "JSXIdentifier" &&
attribute.name.name === "className" &&
attribute.value.type === "StringLiteral" &&
attribute.value.value.split(" ").includes("button")
);
});
if (!hasButtonClassName) return;
// At this point, it means a violation (eg:
// `<button className="button" />`) was found.
// Print the error, the current file, and
// concatenate the line and column of the
// violation position in the original source
// code. This makes it easy to copy and paste
// into an editor like VSCode and it'll open the
// file with the cursor at that position.
const { start } = node.loc;
console.error(
`${path.resolve(file)}:${start.line}:${start.column + 1}`
);
console.error(
` Found "button" element that should be "Button" component.`
);
}
},
Now, let's run the script with the known violation.
This page is a preview of Practical Abstract Syntax Trees