mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
chore(core): Add skill for creating community node lint rules (no-changelog) (#28165)
Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
1506afba91
commit
8f25ce57f2
@@ -0,0 +1,218 @@
|
||||
---
|
||||
description: >-
|
||||
Create new ESLint rules for the @n8n/eslint-plugin-community-nodes package.
|
||||
Use when adding a lint rule, creating a community node lint, or working on
|
||||
eslint-plugin-community-nodes. Guides rule implementation, tests, docs, and
|
||||
plugin registration.
|
||||
user_invocable: true
|
||||
---
|
||||
|
||||
# Create Community Node Lint Rule
|
||||
|
||||
Guide for adding new ESLint rules to `packages/@n8n/eslint-plugin-community-nodes/`.
|
||||
|
||||
All paths below are relative to `packages/@n8n/eslint-plugin-community-nodes/`.
|
||||
|
||||
## Step 1: Understand the Rule
|
||||
|
||||
Before writing code, clarify:
|
||||
- **What** does the rule detect? (missing property, wrong pattern, bad value)
|
||||
- **Where** does it apply? (`.node.ts` files, credential classes, both)
|
||||
- **Severity**: `error` (must fix) or `warn` (should fix)?
|
||||
- **Fixable?** Can it be auto-fixed safely, or only suggest?
|
||||
- **Scope**: Both `recommended` configs, or exclude from `recommendedWithoutN8nCloudSupport`?
|
||||
|
||||
## Step 2: Implement the Rule
|
||||
|
||||
Create `src/rules/<rule-name>.ts`:
|
||||
|
||||
```typescript
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import {
|
||||
isNodeTypeClass, // or isCredentialTypeClass
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const YourRuleNameRule = createRule({
|
||||
name: 'rule-name',
|
||||
meta: {
|
||||
type: 'problem', // or 'suggestion'
|
||||
docs: {
|
||||
description: 'One-line description of what the rule enforces',
|
||||
},
|
||||
messages: {
|
||||
messageId: 'Human-readable message. Use {{placeholder}} for dynamic data.',
|
||||
},
|
||||
fixable: 'code', // omit if not auto-fixable
|
||||
hasSuggestions: true, // omit if no suggestions
|
||||
schema: [], // add options schema if configurable
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isNodeTypeClass(node)) return;
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (!descriptionProperty) return;
|
||||
|
||||
const descriptionValue = descriptionProperty.value;
|
||||
if (descriptionValue?.type !== AST_NODE_TYPES.ObjectExpression) return;
|
||||
|
||||
// Rule logic here — use findObjectProperty(), getLiteralValue(), etc.
|
||||
|
||||
context.report({
|
||||
node: targetNode,
|
||||
messageId: 'messageId',
|
||||
data: { /* template vars */ },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(targetNode, 'replacement');
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Naming**: Export as `PascalCaseRule` (e.g. `MissingPairedItemRule`). The `name` field is kebab-case.
|
||||
|
||||
**Available AST helpers** — see [reference.md](reference.md) for the full catalog of `ast-utils` and `file-utils` exports.
|
||||
|
||||
## Step 3: Write Tests
|
||||
|
||||
Create `src/rules/<rule-name>.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { YourRuleNameRule } from './rule-name.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
// Helper to generate test code — keeps test cases readable
|
||||
function createNodeCode(/* parameterize the varying parts */): string {
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: { name: 'Test Node' },
|
||||
inputs: [],
|
||||
outputs: [],
|
||||
properties: [],
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('rule-name', YourRuleNameRule, {
|
||||
valid: [
|
||||
{ name: 'class that does not implement INodeType', code: '...' },
|
||||
{ name: 'node with correct pattern', code: createNodeCode(/* correct */) },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'descriptive case name',
|
||||
code: createNodeCode(/* incorrect */),
|
||||
errors: [{ messageId: 'messageId', data: { /* expected template vars */ } }],
|
||||
output: createNodeCode(/* expected after fix */), // or `output: null` if no fix
|
||||
},
|
||||
],
|
||||
});
|
||||
```
|
||||
|
||||
**Test guidelines:**
|
||||
- Always test that non-INodeType classes are skipped (valid case)
|
||||
- Test both the error message and the fixed output for fixable rules
|
||||
- For rules with options, test each option combination
|
||||
- For rules using filesystem, mock with `vi.mock('../utils/file-utils.js')`
|
||||
- For suggestion-only rules, use `errors: [{ messageId, suggestions: [...] }]`
|
||||
|
||||
## Step 4: Register the Rule
|
||||
|
||||
### 4a. Add to `src/rules/index.ts`
|
||||
|
||||
```typescript
|
||||
import { YourRuleNameRule } from './rule-name.js';
|
||||
|
||||
// Add to the rules object:
|
||||
export const rules = {
|
||||
// ... existing rules
|
||||
'rule-name': YourRuleNameRule,
|
||||
} satisfies Record<string, AnyRuleModule>;
|
||||
```
|
||||
|
||||
### 4b. Add to `src/plugin.ts` configs
|
||||
|
||||
Add to **both** config objects (unless the rule depends on n8n cloud features):
|
||||
|
||||
```typescript
|
||||
'@n8n/community-nodes/rule-name': 'error', // or 'warn'
|
||||
```
|
||||
|
||||
- Use `error` for rules that catch bugs or required patterns
|
||||
- Use `warn` for style/convention rules (like `options-sorted-alphabetically`)
|
||||
- If the rule uses `no-restricted-globals` or `no-restricted-imports` patterns,
|
||||
only add to `recommended` (not `recommendedWithoutN8nCloudSupport`)
|
||||
|
||||
## Step 5: Write Documentation
|
||||
|
||||
Create `docs/rules/<rule-name>.md`:
|
||||
|
||||
```markdown
|
||||
# Description of what the rule does (`@n8n/community-nodes/rule-name`)
|
||||
|
||||
<!-- end auto-generated rule header -->
|
||||
|
||||
## Rule Details
|
||||
|
||||
Explain why this rule exists and what problem it prevents.
|
||||
|
||||
## Examples
|
||||
|
||||
### Incorrect
|
||||
|
||||
\`\`\`typescript
|
||||
// code that triggers the rule
|
||||
\`\`\`
|
||||
|
||||
### Correct
|
||||
|
||||
\`\`\`typescript
|
||||
// code that passes the rule
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
The header above `<!-- end auto-generated rule header -->` will be regenerated by `pnpm build:docs`. Write a reasonable first version — it gets overwritten.
|
||||
|
||||
## Step 6: Verify
|
||||
|
||||
Run from `packages/@n8n/eslint-plugin-community-nodes/`:
|
||||
|
||||
```bash
|
||||
pushd packages/@n8n/eslint-plugin-community-nodes
|
||||
pnpm test <rule-name>.test.ts # tests pass
|
||||
pnpm typecheck # types are clean
|
||||
pnpm build # compiles
|
||||
pnpm build:docs # regenerates doc headers and README table
|
||||
pnpm lint:docs # docs match schema
|
||||
popd
|
||||
```
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Rule file: `src/rules/<rule-name>.ts`
|
||||
- [ ] Test file: `src/rules/<rule-name>.test.ts`
|
||||
- [ ] Registered in `src/rules/index.ts`
|
||||
- [ ] Added to configs in `src/plugin.ts`
|
||||
- [ ] Doc file: `docs/rules/<rule-name>.md`
|
||||
- [ ] README table updated via `pnpm build:docs`
|
||||
- [ ] All verification commands pass
|
||||
@@ -0,0 +1,85 @@
|
||||
# AST & File Utilities Reference
|
||||
|
||||
Helpers available from `../utils/index.js`. Use these instead of writing custom AST traversal.
|
||||
|
||||
## ast-utils.ts
|
||||
|
||||
### Class/Interface detection
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `isNodeTypeClass(node)` | `boolean` | Check if class implements `INodeType` or extends `Node` |
|
||||
| `isCredentialTypeClass(node)` | `boolean` | Check if class implements `ICredentialType` |
|
||||
|
||||
### Property finding
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `findClassProperty(node, name)` | `PropertyDefinition \| null` | Find a property on a class (e.g. `description`, `icon`) |
|
||||
| `findObjectProperty(obj, name)` | `Property \| null` | Find a property in an object literal (Identifier key) |
|
||||
| `findJsonProperty(obj, name)` | `Property \| null` | Find a property with a Literal key (JSON-style `"key"`) |
|
||||
| `findArrayLiteralProperty(obj, name)` | `Property \| null` | Find a property whose value is an ArrayExpression |
|
||||
|
||||
### Value extraction
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `getLiteralValue(node)` | `string \| boolean \| number \| null` | Extract primitive from a Literal node |
|
||||
| `getStringLiteralValue(node)` | `string \| null` | Extract string specifically |
|
||||
| `getBooleanLiteralValue(node)` | `boolean \| null` | Extract boolean specifically |
|
||||
| `getModulePath(node)` | `string \| null` | Get import path from string literal or template literal |
|
||||
|
||||
### Array operations
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `hasArrayLiteralValue(arr, value)` | `boolean` | Check if array contains a specific string literal |
|
||||
| `extractCredentialInfoFromArray(element)` | `{ name, testedBy } \| null` | Parse credential object from array element |
|
||||
| `extractCredentialNameFromArray(element)` | `string \| null` | Get just the credential name from array element |
|
||||
|
||||
### Method matching
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `isThisHelpersAccess(node)` | `boolean` | Match `this.helpers` member expression |
|
||||
| `isThisMethodCall(node, method)` | `boolean` | Match `this.methodName(...)` calls |
|
||||
| `isThisHelpersMethodCall(node, method)` | `boolean` | Match `this.helpers.methodName(...)` calls |
|
||||
|
||||
### Similarity
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `findSimilarStrings(target, candidates, maxDistance?)` | `string[]` | Suggest similar names (Levenshtein distance) |
|
||||
|
||||
## file-utils.ts
|
||||
|
||||
### Path operations
|
||||
|
||||
| Function | Use when |
|
||||
|----------|----------|
|
||||
| `isContainedWithin(child, parent)` | Check path is within a directory |
|
||||
| `safeJoinPath(base, ...parts)` | Join paths with traversal prevention |
|
||||
|
||||
### Package.json
|
||||
|
||||
| Function | Returns | Use when |
|
||||
|----------|---------|----------|
|
||||
| `findPackageJson(startDir)` | `string \| null` | Walk up to find nearest package.json |
|
||||
| `readPackageJsonN8n(startDir)` | `N8nPackageJson \| null` | Parse n8n config section |
|
||||
| `readPackageJsonCredentials(startDir)` | `Set<string>` | Get credential names from package.json |
|
||||
| `readPackageJsonNodes(startDir)` | `string[]` | Get resolved node file paths |
|
||||
|
||||
### File system
|
||||
|
||||
| Function | Use when |
|
||||
|----------|----------|
|
||||
| `validateIconPath(filePath, iconValue)` | Check icon file exists and is SVG |
|
||||
| `extractCredentialNameFromFile(filePath)` | Parse credential class name from file |
|
||||
| `fileExistsWithCaseSync(filePath)` | Case-sensitive existence check |
|
||||
| `findSimilarSvgFiles(dir, name)` | Suggest similar SVG filenames |
|
||||
|
||||
### Credential verification
|
||||
|
||||
| Function | Use when |
|
||||
|----------|----------|
|
||||
| `areAllCredentialUsagesTestedByNodes(startDir)` | Check all credentials have testedBy |
|
||||
Reference in New Issue
Block a user