Skip to content

Commit 7d7cf32

Browse files
authored
Merge pull request #2964 from Strategy11/add_new_custom_eslint_rules
Add new custom eslint rules
2 parents 2593afb + 28a23ae commit 7d7cf32

31 files changed

Lines changed: 741 additions & 118 deletions

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ mago.toml export-ignore
4646
/resources/ export-ignore
4747
webpack.dev.js export-ignore
4848
.browserslistrc export-ignore
49+
/eslint-rules/ export-ignore
4950
/phpcs-sniffs/ export-ignore
5051
.deepsource.toml export-ignore
5152
.semgrepignore export-ignore
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
---
2+
name: eslint-rules
3+
description: Creating and maintaining custom ESLint rules for Formidable Forms. Use when adding new custom ESLint rules, modifying existing ones, or debugging rule behavior.
4+
---
5+
6+
# Custom ESLint Rules
7+
8+
Workflow for creating and maintaining custom ESLint rules in the Formidable Forms plugin.
9+
10+
## When to Use
11+
12+
- Adding a new custom ESLint rule
13+
- Modifying an existing custom rule
14+
- Debugging why a custom rule is not catching a pattern
15+
- Running ESLint with custom rules
16+
17+
---
18+
19+
## Architecture
20+
21+
Custom ESLint rules live in `/eslint-rules/` at the project root.
22+
23+
```
24+
eslint-rules/
25+
├── index.js # Plugin entry point, exports all rules
26+
└── rules/ # Individual rule files
27+
├── prefer-strict-comparison.js
28+
├── no-redundant-undefined-check.js
29+
├── prefer-includes.js
30+
└── no-typeof-undefined.js
31+
```
32+
33+
The plugin is imported in `eslint.config.mjs` as `formidable` and rules are referenced as `formidable/<rule-name>`.
34+
35+
### Release Exclusions
36+
37+
The `/eslint-rules/` directory is excluded from releases via:
38+
- `.gitattributes`: `export-ignore`
39+
- `bin/zip-plugin.sh`: `-x "*/eslint-rules/*"`
40+
41+
This mirrors the pattern used for `/phpcs-sniffs/`.
42+
43+
---
44+
45+
## Existing Rules
46+
47+
### formidable/prefer-strict-comparison
48+
49+
Enforces `===` and `!==` instead of `==` and `!=` when comparing against non-empty, non-numeric string literals. Mirrors the PHP sniff `PreferStrictComparisonSniff`.
50+
51+
- **Fixable:** Yes
52+
- **Safe strings:** Non-empty and non-numeric (e.g., `'string'`, `'post'`)
53+
- **Unsafe strings (skipped):** `''`, `'0'`, `'123'`, `'1.5'`
54+
55+
### formidable/no-redundant-undefined-check
56+
57+
Detects `x !== undefined && x` patterns where the undefined check is redundant because the truthy check already covers it.
58+
59+
- **Fixable:** Yes
60+
- **Pattern:** `expr !== undefined && expr` becomes just `expr`
61+
62+
### formidable/prefer-includes
63+
64+
Detects `.indexOf()` comparisons with `-1` and suggests `.includes()` instead. Catches yoda-style patterns that `unicorn/prefer-includes` misses (e.g., `-1 !== [].indexOf(x)`).
65+
66+
- **Fixable:** Yes
67+
- **Patterns caught:**
68+
- `arr.indexOf(x) !== -1` and yoda `-1 !== arr.indexOf(x)`
69+
- `arr.indexOf(x) === -1` and yoda `-1 === arr.indexOf(x)`
70+
- `arr.indexOf(x) > -1` and yoda `-1 < arr.indexOf(x)`
71+
- `arr.indexOf(x) >= 0`
72+
73+
### formidable/no-typeof-undefined
74+
75+
Detects `typeof x === 'undefined'` and yoda `'undefined' === typeof x` patterns. Replaces with direct `x === undefined` comparison. Catches yoda-style patterns that `unicorn/no-typeof-undefined` misses.
76+
77+
- **Fixable:** Yes
78+
- **Patterns caught:**
79+
- `typeof x === 'undefined'` / `typeof x == 'undefined'`
80+
- `'undefined' === typeof x` / `'undefined' == typeof x` (yoda)
81+
- Both `===`/`!==` and `==`/`!=` variants
82+
83+
---
84+
85+
## Adding a New Rule
86+
87+
### Step 1: Create the Rule File
88+
89+
Create a new file in `eslint-rules/rules/<rule-name>.js`. Follow the existing pattern:
90+
91+
```javascript
92+
'use strict';
93+
94+
module.exports = {
95+
meta: {
96+
type: 'suggestion', // 'suggestion', 'problem', or 'layout'
97+
docs: {
98+
description: 'Description of what the rule enforces.',
99+
},
100+
fixable: 'code', // 'code' if auto-fixable, null otherwise
101+
schema: [], // JSON Schema for rule options
102+
messages: {
103+
messageId: 'Error message with {{placeholder}}.',
104+
},
105+
},
106+
107+
create( context ) {
108+
const sourceCode = context.sourceCode;
109+
110+
return {
111+
// AST node visitor(s)
112+
BinaryExpression( node ) {
113+
// Rule logic
114+
context.report({
115+
node,
116+
messageId: 'messageId',
117+
data: { placeholder: 'value' },
118+
fix( fixer ) {
119+
return fixer.replaceText( node, 'replacement' );
120+
},
121+
});
122+
},
123+
};
124+
},
125+
};
126+
```
127+
128+
### Step 2: Register the Rule
129+
130+
Add the rule to `eslint-rules/index.js`:
131+
132+
```javascript
133+
const newRule = require( './rules/new-rule' );
134+
135+
module.exports = {
136+
rules: {
137+
// ... existing rules
138+
'new-rule': newRule,
139+
},
140+
};
141+
```
142+
143+
### Step 3: Enable in Config
144+
145+
Add to the rules section in `eslint.config.mjs`:
146+
147+
```javascript
148+
'formidable/new-rule': 'error',
149+
```
150+
151+
### Step 4: Verify
152+
153+
```bash
154+
# Check for violations (requires nvm for node)
155+
export PATH="$HOME/.nvm/versions/node/v20.19.2/bin:$PATH"
156+
./node_modules/.bin/eslint .
157+
158+
# Auto-fix all violations
159+
./node_modules/.bin/eslint . --fix
160+
```
161+
162+
Or use the npm scripts:
163+
164+
```bash
165+
npm run lint
166+
npm run lint:fix
167+
```
168+
169+
---
170+
171+
## Design Principles
172+
173+
1. **All rules must support `--fix`** so they can be applied to the existing codebase automatically
174+
2. **Handle yoda-style comparisons** since the WordPress coding standard historically used yoda conditions
175+
3. **Only enforce safe transformations** (e.g., prefer-strict-comparison skips empty and numeric strings)
176+
4. **Complement existing plugins** by catching patterns that unicorn, sonarjs, etc. miss
177+
5. **Mirror PHP sniffs where applicable** to maintain consistency between PHP and JS linting (see `/phpcs-sniffs/`)
178+
179+
---
180+
181+
## AST Explorer
182+
183+
Use https://astexplorer.net/ with the `espree` parser to inspect AST node types when developing rules. This helps identify the correct node visitors and property names.
184+
185+
## Invocation
186+
187+
Cascade automatically invokes this skill when your request involves custom ESLint rules.
188+
189+
To manually invoke:
190+
191+
```text
192+
@eslint-rules
193+
```

bin/zip-plugin.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ zip -r $zipname $destination \
121121
-x "*/mago.toml" \
122122
-x "formidable-ai/resources/*" \
123123
-x "*/webpack.dev.js" \
124+
-x "*/eslint-rules/*" \
124125
-x "*/phpcs-sniffs/*" \
125126
-x "$source/venv/*" \
126127
-x "formidable/resources/*" \

eslint-rules/index.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict';
2+
3+
const preferStrictComparison = require( './rules/prefer-strict-comparison' );
4+
const noRedundantUndefinedCheck = require( './rules/no-redundant-undefined-check' );
5+
const preferIncludes = require( './rules/prefer-includes' );
6+
const noTypeofUndefined = require( './rules/no-typeof-undefined' );
7+
8+
module.exports = {
9+
rules: {
10+
'prefer-strict-comparison': preferStrictComparison,
11+
'no-redundant-undefined-check': noRedundantUndefinedCheck,
12+
'prefer-includes': preferIncludes,
13+
'no-typeof-undefined': noTypeofUndefined,
14+
},
15+
};
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
'use strict';
2+
3+
/**
4+
* Checks if two AST nodes represent the same expression.
5+
*
6+
* @param {Object} a First AST node.
7+
* @param {Object} b Second AST node.
8+
* @param {Object} sourceCode The source code object.
9+
* @return {boolean} Whether the nodes represent the same expression.
10+
*/
11+
function isSameExpression( a, b, sourceCode ) {
12+
return sourceCode.getText( a ) === sourceCode.getText( b );
13+
}
14+
15+
/**
16+
* Checks if a node is an undefined check (x !== undefined or undefined !== x).
17+
*
18+
* @param {Object} node The AST node.
19+
* @return {Object|null} The expression being checked, or null.
20+
*/
21+
function getUndefinedCheckExpression( node ) {
22+
if ( node.type !== 'BinaryExpression' || node.operator !== '!==' ) {
23+
return null;
24+
}
25+
26+
if ( node.right.type === 'Identifier' && node.right.name === 'undefined' ) {
27+
return node.left;
28+
}
29+
30+
if ( node.left.type === 'Identifier' && node.left.name === 'undefined' ) {
31+
return node.right;
32+
}
33+
34+
return null;
35+
}
36+
37+
module.exports = {
38+
meta: {
39+
type: 'suggestion',
40+
docs: {
41+
description: 'Disallow redundant undefined checks before truthy checks (e.g., `x !== undefined && x` simplifies to `x`).',
42+
},
43+
fixable: 'code',
44+
schema: [],
45+
messages: {
46+
redundant: 'The `!== undefined` check is redundant because the truthy check already covers it. Use just `{{expression}}`.',
47+
},
48+
},
49+
50+
create( context ) {
51+
const sourceCode = context.sourceCode;
52+
53+
return {
54+
LogicalExpression( node ) {
55+
if ( node.operator !== '&&' ) {
56+
return;
57+
}
58+
59+
const { left, right } = node;
60+
61+
// Pattern: x !== undefined && x
62+
const checkedExpression = getUndefinedCheckExpression( left );
63+
if ( checkedExpression === null ) {
64+
return;
65+
}
66+
67+
if ( ! isSameExpression( checkedExpression, right, sourceCode ) ) {
68+
return;
69+
}
70+
71+
context.report({
72+
node,
73+
messageId: 'redundant',
74+
data: {
75+
expression: sourceCode.getText( right ),
76+
},
77+
fix( fixer ) {
78+
return fixer.replaceText( node, sourceCode.getText( right ) );
79+
},
80+
});
81+
},
82+
};
83+
},
84+
};

0 commit comments

Comments
 (0)