first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('ai-node-package-json', AiNodePackageJsonRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'both n8n.aiNodeSdkVersion and ai-node-sdk peer dependency present',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 }, "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
|
||||
},
|
||||
{
|
||||
name: 'neither n8n.aiNodeSdkVersion nor ai-node-sdk present (non-AI package)',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'n8n section without aiNodeSdkVersion and no ai-node-sdk peer dep',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] } }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file is ignored',
|
||||
filename: 'some-config.json',
|
||||
code: '{ "n8n": { "aiNodeSdkVersion": 1 } }',
|
||||
},
|
||||
{
|
||||
name: 'peerDependencies without ai-node-sdk and no aiNodeSdkVersion',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] }, "peerDependencies": { "n8n-workflow": "*" } }',
|
||||
},
|
||||
{
|
||||
name: 'aiNodeSdkVersion as a larger positive integer with multiple peer deps',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 42 }, "peerDependencies": { "n8n-workflow": "^1.0.0", "ai-node-sdk": "^1.0.0" } }',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion present but ai-node-sdk missing from peerDependencies',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 } }',
|
||||
errors: [{ messageId: 'missingPeerDep' }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion present but peerDependencies has other deps only',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1 }, "peerDependencies": { "n8n-workflow": "*" } }',
|
||||
errors: [{ messageId: 'missingPeerDep' }],
|
||||
},
|
||||
{
|
||||
name: 'ai-node-sdk in peerDependencies but n8n.aiNodeSdkVersion missing',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "nodes": [] }, "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'missingSdkVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'ai-node-sdk in peerDependencies but no n8n section at all',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "peerDependencies": { "n8n-workflow": "*", "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'missingSdkVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is a string instead of integer',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": "1" }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: '1' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is zero',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 0 }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: '0' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is negative (parsed as UnaryExpression, not Literal)',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": -1 }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: 'non-literal' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is a float',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": 1.5 }, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'invalidSdkVersion', data: { value: '1.5' } }],
|
||||
},
|
||||
{
|
||||
name: 'n8n.aiNodeSdkVersion is invalid and ai-node-sdk peer dep is missing (two errors)',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "n8n": { "aiNodeSdkVersion": "bad" } }',
|
||||
errors: [
|
||||
{ messageId: 'invalidSdkVersion', data: { value: 'bad' } },
|
||||
{ messageId: 'missingPeerDep' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'aiNodeSdkVersion at root level instead of inside n8n section',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "aiNodeSdkVersion": 1, "peerDependencies": { "ai-node-sdk": "*" } }',
|
||||
errors: [{ messageId: 'wrongLocation' }, { messageId: 'missingSdkVersion' }],
|
||||
},
|
||||
{
|
||||
name: 'aiNodeSdkVersion at root level without peer dep',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "aiNodeSdkVersion": 1 }',
|
||||
errors: [{ messageId: 'wrongLocation' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule, findJsonProperty } from '../utils/index.js';
|
||||
|
||||
export const AiNodePackageJsonRule = createRule({
|
||||
name: 'ai-node-package-json',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce consistency between n8n.aiNodeSdkVersion and ai-node-sdk peer dependency in community node packages',
|
||||
},
|
||||
messages: {
|
||||
missingPeerDep:
|
||||
'Package declares "n8n.aiNodeSdkVersion" but is missing "ai-node-sdk" in peerDependencies. Add "ai-node-sdk": "*" to peerDependencies.',
|
||||
missingSdkVersion:
|
||||
'Package has "ai-node-sdk" in peerDependencies but is missing "aiNodeSdkVersion" in the "n8n" section of package.json.',
|
||||
invalidSdkVersion: '"n8n.aiNodeSdkVersion" must be a positive integer, got {{ value }}.',
|
||||
wrongLocation:
|
||||
'"aiNodeSdkVersion" must be inside the "n8n" section, not at the root level of package.json.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!context.filename.endsWith('package.json')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ObjectExpression(node: TSESTree.ObjectExpression) {
|
||||
// Only process the root object, not nested ones
|
||||
if (node.parent?.type === AST_NODE_TYPES.Property) {
|
||||
return;
|
||||
}
|
||||
|
||||
const n8nProp = findJsonProperty(node, 'n8n');
|
||||
const n8nObject =
|
||||
n8nProp?.value.type === AST_NODE_TYPES.ObjectExpression ? n8nProp.value : null;
|
||||
|
||||
const aiNodeSdkVersionProp = n8nObject
|
||||
? findJsonProperty(n8nObject, 'aiNodeSdkVersion')
|
||||
: null;
|
||||
|
||||
const rootAiNodeSdkVersionProp = findJsonProperty(node, 'aiNodeSdkVersion');
|
||||
const peerDependenciesProp = findJsonProperty(node, 'peerDependencies');
|
||||
|
||||
const hasAiNodeSdkVersion = aiNodeSdkVersionProp !== null;
|
||||
const hasAiNodeSdkPeerDep =
|
||||
peerDependenciesProp?.value.type === AST_NODE_TYPES.ObjectExpression &&
|
||||
findJsonProperty(peerDependenciesProp.value, 'ai-node-sdk') !== null;
|
||||
|
||||
// Catch aiNodeSdkVersion placed at root level instead of inside n8n
|
||||
if (rootAiNodeSdkVersionProp) {
|
||||
context.report({
|
||||
node: rootAiNodeSdkVersionProp,
|
||||
messageId: 'wrongLocation',
|
||||
});
|
||||
}
|
||||
|
||||
// Validate aiNodeSdkVersion is a positive integer when present
|
||||
if (hasAiNodeSdkVersion) {
|
||||
const valueNode = aiNodeSdkVersionProp.value;
|
||||
if (valueNode.type !== AST_NODE_TYPES.Literal || !isPositiveInteger(valueNode.value)) {
|
||||
context.report({
|
||||
node: aiNodeSdkVersionProp,
|
||||
messageId: 'invalidSdkVersion',
|
||||
data: {
|
||||
value: String(
|
||||
valueNode.type === AST_NODE_TYPES.Literal ? valueNode.value : 'non-literal',
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If aiNodeSdkVersion is declared, ai-node-sdk must be in peerDependencies
|
||||
if (hasAiNodeSdkVersion && !hasAiNodeSdkPeerDep) {
|
||||
context.report({
|
||||
node: aiNodeSdkVersionProp,
|
||||
messageId: 'missingPeerDep',
|
||||
});
|
||||
}
|
||||
|
||||
// If ai-node-sdk is in peerDependencies, aiNodeSdkVersion must be declared
|
||||
if (hasAiNodeSdkPeerDep && !hasAiNodeSdkVersion) {
|
||||
context.report({
|
||||
node: peerDependenciesProp,
|
||||
messageId: 'missingSdkVersion',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isPositiveInteger(value: unknown): boolean {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createCredentialCode(documentationUrl: string): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
documentationUrl = '${documentationUrl}';
|
||||
|
||||
properties: INodeProperties[] = [];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createCredentialWithoutDocUrl(): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
|
||||
properties: INodeProperties[] = [];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createRegularClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
documentationUrl = 'invalid-url';
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-documentation-url', CredentialDocumentationUrlRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'valid URL with default options (URLs only)',
|
||||
code: createCredentialCode('https://example.com/docs'),
|
||||
},
|
||||
{
|
||||
name: 'valid URL with explicit options',
|
||||
code: createCredentialCode('https://example.com/docs'),
|
||||
options: [{ allowUrls: true, allowSlugs: false }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug when slugs are allowed',
|
||||
code: createCredentialCode('myservice'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug with slashes when slugs are allowed',
|
||||
code: createCredentialCode('myservice/advanced/config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid URL when both URLs and slugs are allowed',
|
||||
code: createCredentialCode('https://example.com/docs'),
|
||||
options: [{ allowUrls: true, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug when both URLs and slugs are allowed',
|
||||
code: createCredentialCode('myservice/config'),
|
||||
options: [{ allowUrls: true, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'credential without documentationUrl should not trigger',
|
||||
code: createCredentialWithoutDocUrl(),
|
||||
},
|
||||
{
|
||||
name: 'class not implementing ICredentialType should be ignored',
|
||||
code: createRegularClass(),
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase slug with multiple segments',
|
||||
code: createCredentialCode('myservice/somefeature/advancedconfig'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase alphanumeric slug',
|
||||
code: createCredentialCode('myservice123'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
{
|
||||
name: 'valid lowercase alphanumeric slug with slashes',
|
||||
code: createCredentialCode('myservice123/config456'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'invalid URL with default options',
|
||||
code: createCredentialCode('invalid-url'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'invalid-url',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug not allowed with default options',
|
||||
code: createCredentialCode('myservice'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'myservice',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with special characters should not be autofixable',
|
||||
code: createCredentialCode('My-Service'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'My-Service',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'uppercase slug should be autofixable',
|
||||
code: createCredentialCode('MyService'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
output: createCredentialCode('myservice'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'MyService',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid URL when only URLs are allowed',
|
||||
code: createCredentialCode('not-a-valid-url'),
|
||||
options: [{ allowUrls: true, allowSlugs: false }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'not-a-valid-url',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid when neither URLs nor slugs are allowed',
|
||||
code: createCredentialCode('https://example.com'),
|
||||
options: [{ allowUrls: false, allowSlugs: false }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'https://example.com',
|
||||
expectedFormats: 'a valid format (none configured)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with invalid characters (special chars) should not be autofixable',
|
||||
code: createCredentialCode('my@service/config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'my@service/config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with uppercase segment should be autofixable',
|
||||
code: createCredentialCode('myService/Config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
output: createCredentialCode('myservice/config'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'myService/Config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with hyphens should not be autofixable',
|
||||
code: createCredentialCode('myservice/advanced-config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'myservice/advanced-config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug with underscores should not be autofixable',
|
||||
code: createCredentialCode('my_service/config_advanced'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'my_service/config_advanced',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid value when both formats are allowed - shows both in error message',
|
||||
code: createCredentialCode('Invalid-Value!'),
|
||||
options: [{ allowUrls: true, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'Invalid-Value!',
|
||||
expectedFormats: 'a valid URL or a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'empty string should be invalid with default options',
|
||||
code: createCredentialCode(''),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: '',
|
||||
expectedFormats: 'a valid URL',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'empty string should be invalid when slugs are allowed',
|
||||
code: createCredentialCode(''),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: '',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'mixed case slug with numbers should be autofixable',
|
||||
code: createCredentialCode('MyService123/Config456'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
output: createCredentialCode('myservice123/config456'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: 'MyService123/Config456',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'slug starting with number should be invalid and not autofixable',
|
||||
code: createCredentialCode('123service/config'),
|
||||
options: [{ allowUrls: false, allowSlugs: true }],
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: '123service/config',
|
||||
expectedFormats: 'a lowercase alphanumeric slug (can contain slashes)',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
getStringLiteralValue,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
type RuleOptions = {
|
||||
allowUrls?: boolean;
|
||||
allowSlugs?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_OPTIONS: RuleOptions = {
|
||||
allowUrls: true,
|
||||
allowSlugs: false,
|
||||
};
|
||||
|
||||
function isValidUrl(value: string): boolean {
|
||||
try {
|
||||
new URL(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidSlug(value: string): boolean {
|
||||
// TODO: Remove this special case once these slugs are updated
|
||||
if (
|
||||
['google/service-account', 'google/oauth-single-service', 'google/oauth-generic'].includes(
|
||||
value,
|
||||
)
|
||||
)
|
||||
return true;
|
||||
|
||||
return value.split('/').every((segment) => /^[a-z][a-z0-9]*$/.test(segment));
|
||||
}
|
||||
|
||||
function hasOnlyCaseIssues(value: string): boolean {
|
||||
return value.split('/').every((segment) => /^[a-zA-Z][a-zA-Z0-9]*$/.test(segment));
|
||||
}
|
||||
|
||||
function validateDocumentationUrl(value: string, options: RuleOptions): boolean {
|
||||
return (!!options.allowUrls && isValidUrl(value)) || (!!options.allowSlugs && isValidSlug(value));
|
||||
}
|
||||
|
||||
function getExpectedFormatsMessage(options: RuleOptions): string {
|
||||
const formats = [
|
||||
...(options.allowUrls ? ['a valid URL'] : []),
|
||||
...(options.allowSlugs ? ['a lowercase alphanumeric slug (can contain slashes)'] : []),
|
||||
];
|
||||
|
||||
if (formats.length === 0) return 'a valid format (none configured)';
|
||||
if (formats.length === 1) return formats[0]!;
|
||||
return formats.slice(0, -1).join(', ') + ' or ' + formats[formats.length - 1];
|
||||
}
|
||||
|
||||
export const CredentialDocumentationUrlRule = createRule({
|
||||
name: 'credential-documentation-url',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce valid credential documentationUrl format (URL or lowercase alphanumeric slug)',
|
||||
},
|
||||
messages: {
|
||||
invalidDocumentationUrl: "documentationUrl '{{ value }}' must be {{ expectedFormats }}",
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
allowUrls: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to allow valid URLs',
|
||||
},
|
||||
allowSlugs: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to allow lowercase alphanumeric slugs with slashes',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultOptions: [DEFAULT_OPTIONS],
|
||||
create(context, [options = {}]) {
|
||||
const mergedOptions = { ...DEFAULT_OPTIONS, ...options };
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentationUrlProperty = findClassProperty(node, 'documentationUrl');
|
||||
if (!documentationUrlProperty?.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const documentationUrl = getStringLiteralValue(documentationUrlProperty.value);
|
||||
if (documentationUrl === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateDocumentationUrl(documentationUrl, mergedOptions)) {
|
||||
const canAutofix = !!mergedOptions.allowSlugs && hasOnlyCaseIssues(documentationUrl);
|
||||
|
||||
context.report({
|
||||
node: documentationUrlProperty.value,
|
||||
messageId: 'invalidDocumentationUrl',
|
||||
data: {
|
||||
value: documentationUrl,
|
||||
expectedFormats: getExpectedFormatsMessage(mergedOptions),
|
||||
},
|
||||
fix: canAutofix
|
||||
? (fixer) =>
|
||||
fixer.replaceText(
|
||||
documentationUrlProperty.value!,
|
||||
`'${documentationUrl.toLowerCase()}'`,
|
||||
)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+232
@@ -0,0 +1,232 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createCredentialCode(properties: string[]): string {
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
${properties.map((prop) => `\t\t${prop}`).join(',\n')},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
function createProperty(
|
||||
displayName: string,
|
||||
name: string,
|
||||
options: { password?: boolean; emptyTypeOptions?: boolean } = {},
|
||||
): string {
|
||||
let typeOptionsStr = '';
|
||||
if (options.emptyTypeOptions) {
|
||||
typeOptionsStr = '\n\t\t\ttypeOptions: {},';
|
||||
} else if (options.password !== undefined) {
|
||||
typeOptionsStr = `\n\t\t\ttypeOptions: { password: ${options.password} },`;
|
||||
}
|
||||
|
||||
return `{
|
||||
displayName: '${displayName}',
|
||||
name: '${name}',
|
||||
type: 'string',
|
||||
default: '',${typeOptionsStr}
|
||||
}`;
|
||||
}
|
||||
|
||||
function createOAuth2CredentialCode(hasPasswordProtection: boolean = true): string {
|
||||
const passwordOptions = hasPasswordProtection ? '\n\t\t\ttypeOptions: { password: true },' : '';
|
||||
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class GithubOAuth2Api implements ICredentialType {
|
||||
name = 'githubOAuth2Api';
|
||||
extends = ['oAuth2Api'];
|
||||
displayName = 'GitHub OAuth2 API';
|
||||
|
||||
properties: INodeProperties[] = [
|
||||
{
|
||||
displayName: 'Access Token URL',
|
||||
name: 'accessTokenUrl',
|
||||
type: 'hidden',
|
||||
default: 'https://github.com/login/oauth/access_token',
|
||||
},
|
||||
{
|
||||
displayName: 'Client Secret',
|
||||
name: 'clientSecret',
|
||||
type: 'string',
|
||||
default: '',${passwordOptions}
|
||||
},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create a regular (non-credential) class
|
||||
function createRegularClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
properties = [
|
||||
{
|
||||
name: 'password',
|
||||
type: 'string',
|
||||
},
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-password-field', CredentialPasswordFieldRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'correct usage with password field having typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'field name is not sensitive',
|
||||
code: createCredentialCode([createProperty('Base URL', 'baseUrl')]),
|
||||
},
|
||||
{
|
||||
name: 'multiple sensitive fields with correct typeOptions',
|
||||
code: createCredentialCode([
|
||||
createProperty('Password', 'password', { password: true }),
|
||||
createProperty('Secret Token', 'secretToken', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'class does not implement ICredentialType',
|
||||
code: createRegularClass(),
|
||||
},
|
||||
{
|
||||
name: 'OAuth2 credential with URL fields and proper client secret protection',
|
||||
code: createOAuth2CredentialCode(true),
|
||||
},
|
||||
{
|
||||
name: 'public key fields should not be flagged as sensitive',
|
||||
code: createCredentialCode([
|
||||
createProperty('Public Key', 'publicKey'),
|
||||
createProperty('Client ID', 'clientId'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'certificates should be flagged as sensitive (when properly configured)',
|
||||
code: createCredentialCode([
|
||||
createProperty('Certificate', 'certificate', { password: true }),
|
||||
createProperty('CA Certificate', 'caCert', { password: true }),
|
||||
createProperty('Client Certificate', 'clientCert', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'URL fields containing sensitive keywords should not be flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Token URL', 'tokenUrl'),
|
||||
createProperty('Authorization URL', 'authorizationUrl'),
|
||||
createProperty('Access Token URL', 'accessTokenUrl'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'ID fields containing sensitive keywords should not be flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Access Key ID', 'accessKeyId'),
|
||||
createProperty('Key ID', 'keyId'),
|
||||
createProperty('User ID', 'userId'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'file path and name fields should not be flagged',
|
||||
code: createCredentialCode([
|
||||
createProperty('Key File Path', 'keyPath'),
|
||||
createProperty('Key File Name', 'keyFile'),
|
||||
createProperty('Certificate Path', 'keyName'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'should flag actual sensitive fields while ignoring false positives',
|
||||
code: createCredentialCode([
|
||||
createProperty('Private Key', 'privateKey', { password: true }),
|
||||
createProperty('Public Key', 'publicKey'), // Should not be flagged
|
||||
createProperty('Secret Token', 'secretToken', { password: true }),
|
||||
createProperty('Client ID', 'clientId'), // Should not be flagged
|
||||
]),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'password field missing typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('Password', 'password')]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'password' } }],
|
||||
output: createCredentialCode([createProperty('Password', 'password', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'API key field missing typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey')]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'apiKey' } }],
|
||||
output: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'secret field missing typeOptions.password = true',
|
||||
code: createCredentialCode([createProperty('Client Secret', 'clientSecret')]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'clientSecret' } }],
|
||||
output: createCredentialCode([
|
||||
createProperty('Client Secret', 'clientSecret', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'multiple invalid fields',
|
||||
code: createCredentialCode([
|
||||
createProperty('Password', 'password'),
|
||||
createProperty('Username', 'username'),
|
||||
createProperty('Access Token', 'accessToken'),
|
||||
]),
|
||||
errors: [
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'password' } },
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'accessToken' } },
|
||||
],
|
||||
output: createCredentialCode([
|
||||
createProperty('Password', 'password', { password: true }),
|
||||
createProperty('Username', 'username'),
|
||||
createProperty('Access Token', 'accessToken', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'field has typeOptions but password is false',
|
||||
code: createCredentialCode([createProperty('API Key', 'apiKey', { password: false })]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'apiKey' } }],
|
||||
output: createCredentialCode([createProperty('API Key', 'apiKey', { password: true })]),
|
||||
},
|
||||
{
|
||||
name: 'OAuth2 credential with missing password protection for clientSecret',
|
||||
code: createOAuth2CredentialCode(false),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'clientSecret' } }],
|
||||
output: createOAuth2CredentialCode(true),
|
||||
},
|
||||
{
|
||||
name: 'field has empty typeOptions object',
|
||||
code: createCredentialCode([
|
||||
createProperty('Access Token', 'accessToken', { emptyTypeOptions: true }),
|
||||
]),
|
||||
errors: [{ messageId: 'missingPasswordOption', data: { fieldName: 'accessToken' } }],
|
||||
output: createCredentialCode([
|
||||
createProperty('Access Token', 'accessToken', { password: true }),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: 'certificate fields should require password protection',
|
||||
code: createCredentialCode([
|
||||
createProperty('Certificate', 'certificate'),
|
||||
createProperty('Client Certificate', 'clientCert'),
|
||||
]),
|
||||
errors: [
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'certificate' } },
|
||||
{ messageId: 'missingPasswordOption', data: { fieldName: 'clientCert' } },
|
||||
],
|
||||
output: createCredentialCode([
|
||||
createProperty('Certificate', 'certificate', { password: true }),
|
||||
createProperty('Client Certificate', 'clientCert', { password: true }),
|
||||
]),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { ReportFixFunction } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
getBooleanLiteralValue,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const SENSITIVE_PATTERNS = [
|
||||
'password',
|
||||
'secret',
|
||||
'token',
|
||||
'cert',
|
||||
'passphrase',
|
||||
'apikey',
|
||||
'secretkey',
|
||||
'privatekey',
|
||||
'authkey',
|
||||
];
|
||||
|
||||
const NON_SENSITIVE_PATTERNS = ['url', 'pub', 'id'];
|
||||
|
||||
function isSensitiveFieldName(name: string): boolean {
|
||||
const lowerName = name.toLowerCase();
|
||||
|
||||
if (NON_SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return SENSITIVE_PATTERNS.some((pattern) => lowerName.includes(pattern));
|
||||
}
|
||||
|
||||
function hasPasswordTypeOption(element: TSESTree.ObjectExpression): boolean {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
if (typeOptionsProperty?.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
|
||||
const passwordValue = passwordProperty ? getBooleanLiteralValue(passwordProperty.value) : null;
|
||||
|
||||
return passwordValue === true;
|
||||
}
|
||||
|
||||
function createPasswordFix(
|
||||
element: TSESTree.ObjectExpression,
|
||||
typeOptionsProperty: TSESTree.Property | null,
|
||||
): ReportFixFunction {
|
||||
return (fixer) => {
|
||||
if (typeOptionsProperty?.value.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const passwordProperty = findObjectProperty(typeOptionsProperty.value, 'password');
|
||||
|
||||
if (passwordProperty) {
|
||||
return fixer.replaceText(passwordProperty.value, 'true');
|
||||
}
|
||||
|
||||
const objectValue = typeOptionsProperty.value;
|
||||
if (objectValue.properties.length > 0) {
|
||||
const lastProperty = objectValue.properties[objectValue.properties.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ', password: true');
|
||||
}
|
||||
} else {
|
||||
const range = objectValue.range;
|
||||
if (range) {
|
||||
const openBrace = range[0] + 1;
|
||||
return fixer.insertTextAfterRange([openBrace, openBrace], ' password: true ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastProperty = element.properties[element.properties.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\t\ttypeOptions: { password: true }');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
export const CredentialPasswordFieldRule = createRule({
|
||||
name: 'credential-password-field',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure credential fields with sensitive names have typeOptions.password = true',
|
||||
},
|
||||
messages: {
|
||||
missingPasswordOption:
|
||||
"Field '{{ fieldName }}' appears to be a sensitive field but is missing 'typeOptions: { password: true }'",
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesProperty = findClassProperty(node, 'properties');
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== TSESTree.AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const element of propertiesProperty.value.elements) {
|
||||
if (element?.type !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameProperty = findObjectProperty(element, 'name');
|
||||
const fieldName = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
|
||||
|
||||
if (!fieldName || !isSensitiveFieldName(fieldName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasPasswordTypeOption(element)) {
|
||||
const typeOptionsProperty = findObjectProperty(element, 'typeOptions');
|
||||
|
||||
context.report({
|
||||
node: element,
|
||||
messageId: 'missingPasswordOption',
|
||||
data: { fieldName },
|
||||
fix: createPasswordFix(element, typeOptionsProperty),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
// Helper function to create credential class code
|
||||
function createCredentialCode(options: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
hasTest?: boolean;
|
||||
extends?: string[];
|
||||
extraProperties?: string;
|
||||
}): string {
|
||||
const {
|
||||
name = 'myApi',
|
||||
displayName = 'My API',
|
||||
hasTest = false,
|
||||
extends: extendsArray,
|
||||
extraProperties = '',
|
||||
} = options;
|
||||
|
||||
const imports = hasTest
|
||||
? "import type { ICredentialTestRequest, ICredentialType, INodeProperties } from 'n8n-workflow';"
|
||||
: "import type { ICredentialType, INodeProperties } from 'n8n-workflow';";
|
||||
|
||||
const extendsStr = extendsArray ? `\n\textends = ${JSON.stringify(extendsArray)};` : '';
|
||||
|
||||
const testProperty = hasTest
|
||||
? "\n\n\ttest: ICredentialTestRequest = {\n\t\trequest: {\n\t\t\tbaseURL: 'https://api.example.com',\n\t\t\turl: '/test',\n\t\t},\n\t};"
|
||||
: '';
|
||||
|
||||
return `
|
||||
${imports}
|
||||
|
||||
export class ${name.charAt(0).toUpperCase() + name.slice(1)} implements ICredentialType {
|
||||
name = '${name}';${extendsStr}
|
||||
displayName = '${displayName}';
|
||||
properties: INodeProperties[] = [];${testProperty}${extraProperties}
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNonCredentialClass(className: string = 'SomeOtherClass'): string {
|
||||
return `
|
||||
export class ${className} {
|
||||
name = 'notACredential';
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('credential-test-required', CredentialTestRequiredRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'credential class with test property',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({ hasTest: true }),
|
||||
},
|
||||
{
|
||||
name: 'credential class extending oAuth2Api (exempt)',
|
||||
filename: 'MyOAuth2Api.credentials.ts',
|
||||
code: createCredentialCode({
|
||||
name: 'myOAuth2Api',
|
||||
displayName: 'My OAuth2 API',
|
||||
extends: ['oAuth2Api'],
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'non-credential class ignored',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createNonCredentialClass(),
|
||||
},
|
||||
{
|
||||
name: 'non-credential file ignored',
|
||||
filename: 'regular-file.ts',
|
||||
code: createCredentialCode({}),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'credential class missing test property and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({}),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingCredentialTest',
|
||||
data: { className: 'MyApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addTemplate',
|
||||
output: `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MyApi implements ICredentialType {
|
||||
name = 'myApi';
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'credential class with extends but not oAuth2Api and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({ extends: ['someOtherApi'] }),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingCredentialTest',
|
||||
data: { className: 'MyApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addTemplate',
|
||||
output: `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MyApi implements ICredentialType {
|
||||
name = 'myApi';
|
||||
extends = ["someOtherApi"];
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'credential class with empty extends array and no testedBy in package',
|
||||
filename: 'MyApi.credentials.ts',
|
||||
code: createCredentialCode({ extends: [] }),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingCredentialTest',
|
||||
data: { className: 'MyApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addTemplate',
|
||||
output: `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class MyApi implements ICredentialType {
|
||||
name = 'myApi';
|
||||
extends = [];
|
||||
displayName = 'My API';
|
||||
properties: INodeProperties[] = [];
|
||||
|
||||
test: ICredentialTestRequest = {
|
||||
request: {
|
||||
method: 'GET',
|
||||
url: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
},
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import {
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
hasArrayLiteralValue,
|
||||
isFileType,
|
||||
getStringLiteralValue,
|
||||
findPackageJson,
|
||||
areAllCredentialUsagesTestedByNodes,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const CredentialTestRequiredRule = createRule({
|
||||
name: 'credential-test-required',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure credentials have a credential test',
|
||||
},
|
||||
messages: {
|
||||
addTemplate: 'Add basic credential test template',
|
||||
missingCredentialTest:
|
||||
'Credential class "{{ className }}" must have a test property or be tested by a node via testedBy',
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!isFileType(context.filename, '.credentials.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let packageDir: string | null = null;
|
||||
|
||||
const getPackageDir = (): string | null => {
|
||||
if (packageDir !== null) {
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
const packageJsonPath = findPackageJson(context.filename);
|
||||
if (!packageJsonPath) {
|
||||
packageDir = '';
|
||||
return packageDir;
|
||||
}
|
||||
|
||||
packageDir = dirname(packageJsonPath);
|
||||
return packageDir;
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isCredentialTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const extendsProperty = findClassProperty(node, 'extends');
|
||||
if (extendsProperty && hasArrayLiteralValue(extendsProperty, 'oAuth2Api')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const testProperty = findClassProperty(node, 'test');
|
||||
if (testProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameProperty = findClassProperty(node, 'name');
|
||||
if (!nameProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialName = getStringLiteralValue(nameProperty.value);
|
||||
if (!credentialName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pkgDir = getPackageDir();
|
||||
if (!pkgDir) {
|
||||
const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
|
||||
|
||||
const testProperty = createCredentialTestTemplate();
|
||||
suggestions.push({
|
||||
messageId: 'addTemplate',
|
||||
fix(fixer) {
|
||||
const classBody = node.body.body;
|
||||
const lastProperty = classBody[classBody.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingCredentialTest',
|
||||
data: {
|
||||
className: node.id?.name ?? 'Unknown',
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const allUsagesTestedByNodes = areAllCredentialUsagesTestedByNodes(credentialName, pkgDir);
|
||||
if (!allUsagesTestedByNodes) {
|
||||
const suggestions: ReportSuggestionArray<'addTemplate' | 'missingCredentialTest'> = [];
|
||||
|
||||
const testProperty = createCredentialTestTemplate();
|
||||
suggestions.push({
|
||||
messageId: 'addTemplate',
|
||||
fix(fixer) {
|
||||
const classBody = node.body.body;
|
||||
const lastProperty = classBody[classBody.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, `\n\n${testProperty}`);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingCredentialTest',
|
||||
data: {
|
||||
className: node.id?.name ?? 'Unknown',
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function createCredentialTestTemplate(): string {
|
||||
return `\ttest: ICredentialTestRequest = {
|
||||
\t\trequest: {
|
||||
\t\t\tmethod: 'GET',
|
||||
\t\t\turl: '={{$credentials.server}}/test', // Replace with actual endpoint
|
||||
\t\t},
|
||||
\t};`;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import * as fs from 'node:fs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
vi.mock('node:fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||
const mockReaddirSync = vi.mocked(fs.readdirSync);
|
||||
|
||||
const mockSvgFiles = [
|
||||
'TestNode.svg',
|
||||
'ValidIcon.svg',
|
||||
'ValidIcon.dark.svg',
|
||||
'SameIcon.svg',
|
||||
'github.svg',
|
||||
];
|
||||
|
||||
function setupMockFileSystem() {
|
||||
mockExistsSync.mockImplementation((path: fs.PathLike) => {
|
||||
const pathStr = path.toString();
|
||||
|
||||
if (mockSvgFiles.some((file) => pathStr.includes(file)) || pathStr.includes('NotSvg.png')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathStr.endsWith('/tmp/icons') || pathStr.endsWith('/tmp') || pathStr.endsWith('icons')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
// @ts-expect-error Typescript does not select the correct overload
|
||||
mockReaddirSync.mockImplementation((path: fs.PathLike): string[] => {
|
||||
const pathStr = path.toString();
|
||||
|
||||
if (pathStr.includes('icons')) {
|
||||
return [...mockSvgFiles, 'NotSvg.png'];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
setupMockFileSystem();
|
||||
|
||||
const nodeFilePath = '/tmp/TestNode.node.ts';
|
||||
const credentialFilePath = '/tmp/TestCredential.credentials.ts';
|
||||
|
||||
function createNodeCode(
|
||||
icon?: string | { light: string; dark: string },
|
||||
includeTypeImport: boolean = false,
|
||||
): string {
|
||||
const typeImport = includeTypeImport
|
||||
? "import type { INodeType, INodeTypeDescription } from 'n8n-workflow';"
|
||||
: "import type { INodeType } from 'n8n-workflow';";
|
||||
|
||||
const typeAnnotation = includeTypeImport ? ': INodeTypeDescription' : '';
|
||||
|
||||
let iconProperty = '';
|
||||
if (icon) {
|
||||
if (typeof icon === 'string') {
|
||||
iconProperty = `icon: '${icon}',`;
|
||||
} else {
|
||||
iconProperty = `icon: {
|
||||
light: '${icon.light}',
|
||||
dark: '${icon.dark}'
|
||||
},`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
${typeImport}
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description${typeAnnotation} = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
${iconProperty}
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
function createCredentialCode(icon?: string | { light: string; dark: string }): string {
|
||||
let iconProperty = '';
|
||||
if (icon) {
|
||||
if (typeof icon === 'string') {
|
||||
iconProperty = `icon = '${icon}';`;
|
||||
} else {
|
||||
iconProperty = `icon = {
|
||||
light: '${icon.light}',
|
||||
dark: '${icon.dark}'
|
||||
};`;
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
import type { ICredentialType, INodeProperties } from 'n8n-workflow';
|
||||
|
||||
export class TestCredential implements ICredentialType {
|
||||
name = 'testApi';
|
||||
displayName = 'Test API';
|
||||
${iconProperty}
|
||||
properties: INodeProperties[] = [];
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create non-node class
|
||||
function createNonNodeClass(icon: string): string {
|
||||
return `
|
||||
export class NotANode {
|
||||
icon = '${icon}';
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('icon-validation', IconValidationRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'non-node class ignored',
|
||||
filename: nodeFilePath,
|
||||
code: createNonNodeClass('file:nonexistent.png'),
|
||||
},
|
||||
{
|
||||
name: 'non-node file ignored',
|
||||
filename: '/tmp/regular-file.ts',
|
||||
code: createNodeCode('file:nonexistent.svg'),
|
||||
},
|
||||
{
|
||||
name: 'node with valid string icon in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode('file:icons/TestNode.svg', true),
|
||||
},
|
||||
{
|
||||
name: 'node with valid light/dark icons in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(
|
||||
{
|
||||
light: 'file:icons/ValidIcon.svg',
|
||||
dark: 'file:icons/ValidIcon.dark.svg',
|
||||
},
|
||||
true,
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'credential with valid string icon',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode('file:icons/TestNode.svg'),
|
||||
},
|
||||
{
|
||||
name: 'credential with valid light/dark icons',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode({
|
||||
light: 'file:icons/ValidIcon.svg',
|
||||
dark: 'file:icons/ValidIcon.dark.svg',
|
||||
}),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'node missing icon property in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(undefined, true),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingIcon',
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addPlaceholder',
|
||||
output:
|
||||
"\nimport type { INodeType, INodeTypeDescription } from 'n8n-workflow';\n\nexport class TestNode implements INodeType {\n\tdescription: INodeTypeDescription = {\n\t\tdisplayName: 'Test Node',\n\t\tname: 'testNode',\n\t\t\n\t\tgroup: ['input'],\n\t\tversion: 1,\n\t\tdescription: 'A test node',\n\t\tdefaults: {\n\t\t\tname: 'Test Node',\n\t\t},\n\t\tinputs: ['main'],\n\t\toutputs: ['main'],\n\t\tproperties: [],\n\t\ticon: \"file:./icon.svg\",\n\t};\n}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'icon file does not exist in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode('file:icons/NonExistent.svg', true),
|
||||
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'light and dark icons are the same file in description',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(
|
||||
{
|
||||
light: 'file:icons/SameIcon.svg',
|
||||
dark: 'file:icons/SameIcon.svg',
|
||||
},
|
||||
true,
|
||||
),
|
||||
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'credential missing icon property',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode(),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'missingIcon',
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'addPlaceholder',
|
||||
output:
|
||||
"\nimport type { ICredentialType, INodeProperties } from 'n8n-workflow';\n\nexport class TestCredential implements ICredentialType {\n\tname = 'testApi';\n\tdisplayName = 'Test API';\n\t\n\tproperties: INodeProperties[] = [];\n\n\ticon = \"file:./icon.svg\";\n}",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'credential icon file does not exist',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode('file:icons/NonExistent.svg'),
|
||||
errors: [{ messageId: 'iconFileNotFound', data: { iconPath: 'icons/NonExistent.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'credential light and dark icons are the same file',
|
||||
filename: credentialFilePath,
|
||||
code: createCredentialCode({
|
||||
light: 'file:icons/SameIcon.svg',
|
||||
dark: 'file:icons/SameIcon.svg',
|
||||
}),
|
||||
errors: [{ messageId: 'lightDarkSame', data: { iconPath: 'icons/SameIcon.svg' } }],
|
||||
},
|
||||
{
|
||||
name: 'node icon file does not exist but similar file exists - should suggest similar file',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode('file:icons/github2.svg'),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'iconFileNotFound',
|
||||
data: { iconPath: 'icons/github2.svg' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'similarIcon',
|
||||
data: { suggestedName: 'icons/github.svg' },
|
||||
output: `
|
||||
import type { INodeType } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
icon: "file:icons/github.svg",
|
||||
group: ['input'],
|
||||
version: 1,
|
||||
description: 'A test node',
|
||||
defaults: {
|
||||
name: 'Test Node',
|
||||
},
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
import { TSESTree } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
isCredentialTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
validateIconPath,
|
||||
findSimilarSvgFiles,
|
||||
isFileType,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const messages = {
|
||||
iconFileNotFound: 'Icon file "{{ iconPath }}" does not exist',
|
||||
iconNotSvg: 'Icon file "{{ iconPath }}" must be an SVG file (end with .svg)',
|
||||
lightDarkSame: 'Light and dark icons cannot be the same file. Both point to "{{ iconPath }}"',
|
||||
invalidIconPath: 'Icon path "{{ iconPath }}" must use file: protocol and be a string',
|
||||
missingIcon: 'Node/Credential class must have an icon property defined',
|
||||
addPlaceholder: 'Add icon property with placeholder',
|
||||
addFileProtocol: "Add 'file:' protocol to icon path",
|
||||
changeExtension: "Change icon extension to '.svg'",
|
||||
similarIcon: "Use existing icon '{{ suggestedName }}'",
|
||||
} as const;
|
||||
|
||||
export const IconValidationRule = createRule({
|
||||
name: 'icon-validation',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Validate node and credential icon files exist, are SVG format, and light/dark icons are different',
|
||||
},
|
||||
messages,
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (
|
||||
!isFileType(context.filename, '.node.ts') &&
|
||||
!isFileType(context.filename, '.credentials.ts')
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const validateIcon = (iconPath: string | null, node: TSESTree.Node): boolean => {
|
||||
if (!iconPath) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidIconPath',
|
||||
data: { iconPath: iconPath ?? '' },
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentDir = dirname(context.filename);
|
||||
const validation = validateIconPath(iconPath, currentDir);
|
||||
|
||||
if (!validation.isFile) {
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
if (!iconPath.startsWith('file:')) {
|
||||
suggestions.push({
|
||||
messageId: 'addFileProtocol',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `"file:${iconPath}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'invalidIconPath',
|
||||
data: { iconPath },
|
||||
suggest: suggestions,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validation.isSvg) {
|
||||
const relativePath = iconPath.replace(/^file:/, '');
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
const pathWithoutExt = relativePath.replace(/\.[^/.]+$/, '');
|
||||
const svgPath = `${pathWithoutExt}.svg`;
|
||||
suggestions.push({
|
||||
messageId: 'changeExtension',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `"file:${svgPath}"`);
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'iconNotSvg',
|
||||
data: { iconPath: relativePath },
|
||||
suggest: suggestions,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!validation.exists) {
|
||||
const relativePath = iconPath.replace(/^file:/, '');
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
// Find similar SVG files in the same directory
|
||||
const similarFiles = findSimilarSvgFiles(relativePath, currentDir);
|
||||
for (const similarFile of similarFiles) {
|
||||
suggestions.push({
|
||||
messageId: 'similarIcon',
|
||||
data: { suggestedName: similarFile },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node, `"file:${similarFile}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'iconFileNotFound',
|
||||
data: { iconPath: relativePath },
|
||||
suggest: suggestions,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const validateIconValue = (iconValue: TSESTree.Node) => {
|
||||
if (iconValue.type === TSESTree.AST_NODE_TYPES.Literal) {
|
||||
const iconPath = getStringLiteralValue(iconValue);
|
||||
validateIcon(iconPath, iconValue);
|
||||
} else if (iconValue.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const lightProperty = findObjectProperty(iconValue, 'light');
|
||||
const darkProperty = findObjectProperty(iconValue, 'dark');
|
||||
|
||||
const lightPath = lightProperty ? getStringLiteralValue(lightProperty.value) : null;
|
||||
const darkPath = darkProperty ? getStringLiteralValue(darkProperty.value) : null;
|
||||
|
||||
if (lightProperty) {
|
||||
validateIcon(lightPath, lightProperty.value);
|
||||
}
|
||||
if (darkProperty) {
|
||||
validateIcon(darkPath, darkProperty.value);
|
||||
}
|
||||
|
||||
if (lightPath && darkPath && lightPath === darkPath && lightProperty) {
|
||||
context.report({
|
||||
node: lightProperty.value,
|
||||
messageId: 'lightDarkSame',
|
||||
data: { iconPath: lightPath.replace(/^file:/, '') },
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
const isNodeClass = isNodeTypeClass(node);
|
||||
const isCredentialClass = isCredentialTypeClass(node);
|
||||
|
||||
if (!isNodeClass && !isCredentialClass) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isNodeClass) {
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingIcon',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionValue = descriptionProperty.value;
|
||||
const iconProperty = findObjectProperty(descriptionValue, 'icon');
|
||||
if (!iconProperty) {
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
suggestions.push({
|
||||
messageId: 'addPlaceholder',
|
||||
fix(fixer) {
|
||||
const lastProperty =
|
||||
descriptionValue.properties[descriptionValue.properties.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\ticon: "file:./icon.svg"');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingIcon',
|
||||
suggest: suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
validateIconValue(iconProperty.value);
|
||||
} else if (isCredentialClass) {
|
||||
const iconProperty = findClassProperty(node, 'icon');
|
||||
if (!iconProperty?.value) {
|
||||
const suggestions: ReportSuggestionArray<keyof typeof messages> = [];
|
||||
|
||||
suggestions.push({
|
||||
messageId: 'addPlaceholder',
|
||||
fix(fixer) {
|
||||
const classBody = node.body.body;
|
||||
const lastProperty = classBody[classBody.length - 1];
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, '\n\n\ticon = "file:./icon.svg";');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingIcon',
|
||||
suggest: suggestions,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
validateIconValue(iconProperty.value);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { AnyRuleModule } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import { AiNodePackageJsonRule } from './ai-node-package-json.js';
|
||||
import { CredentialDocumentationUrlRule } from './credential-documentation-url.js';
|
||||
import { CredentialPasswordFieldRule } from './credential-password-field.js';
|
||||
import { CredentialTestRequiredRule } from './credential-test-required.js';
|
||||
import { IconValidationRule } from './icon-validation.js';
|
||||
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
||||
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
||||
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
||||
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
||||
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
||||
import { PackageNameConventionRule } from './package-name-convention.js';
|
||||
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
||||
|
||||
export const rules = {
|
||||
'ai-node-package-json': AiNodePackageJsonRule,
|
||||
'no-restricted-globals': NoRestrictedGlobalsRule,
|
||||
'no-restricted-imports': NoRestrictedImportsRule,
|
||||
'credential-password-field': CredentialPasswordFieldRule,
|
||||
'no-deprecated-workflow-functions': NoDeprecatedWorkflowFunctionsRule,
|
||||
'node-usable-as-tool': NodeUsableAsToolRule,
|
||||
'package-name-convention': PackageNameConventionRule,
|
||||
'credential-test-required': CredentialTestRequiredRule,
|
||||
'no-credential-reuse': NoCredentialReuseRule,
|
||||
'icon-validation': IconValidationRule,
|
||||
'resource-operation-pattern': ResourceOperationPatternRule,
|
||||
'credential-documentation-url': CredentialDocumentationUrlRule,
|
||||
} satisfies Record<string, AnyRuleModule>;
|
||||
@@ -0,0 +1,474 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { NoCredentialReuseRule } from './no-credential-reuse.js';
|
||||
import * as fileUtils from '../utils/file-utils.js';
|
||||
|
||||
vi.mock('../utils/file-utils.js', async () => {
|
||||
const actual = await vi.importActual('../utils/file-utils.js');
|
||||
return {
|
||||
...actual,
|
||||
readPackageJsonCredentials: vi.fn(),
|
||||
findPackageJson: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockReadPackageJsonCredentials = vi.mocked(fileUtils.readPackageJsonCredentials);
|
||||
const mockFindPackageJson = vi.mocked(fileUtils.findPackageJson);
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
const nodeFilePath = '/tmp/TestNode.node.ts';
|
||||
|
||||
function createNodeCode(
|
||||
credentials: Array<string | { name: string; required?: boolean }> = [],
|
||||
): string {
|
||||
const credentialsArray =
|
||||
credentials.length > 0
|
||||
? credentials
|
||||
.map((cred) => {
|
||||
if (typeof cred === 'string') {
|
||||
return `'${cred}'`;
|
||||
} else {
|
||||
const required =
|
||||
cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
|
||||
return `{\n\t\t\t\tname: '${cred.name}'${required},\n\t\t\t}`;
|
||||
}
|
||||
})
|
||||
.join(',\n\t\t\t')
|
||||
: '';
|
||||
|
||||
const credentialsProperty =
|
||||
credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
|
||||
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
${credentialsProperty}
|
||||
properties: [],
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create expected outputs with double quotes (matching rule fix behavior)
|
||||
function createExpectedNodeCode(
|
||||
credentials: Array<string | { name: string; required?: boolean }> = [],
|
||||
): string {
|
||||
const credentialsArray =
|
||||
credentials.length > 0
|
||||
? credentials
|
||||
.map((cred) => {
|
||||
if (typeof cred === 'string') {
|
||||
return `"${cred}"`;
|
||||
} else {
|
||||
const required =
|
||||
cred.required !== undefined ? `,\n\t\t\t\trequired: ${cred.required}` : '';
|
||||
return `{\n\t\t\t\tname: "${cred.name}"${required},\n\t\t\t}`;
|
||||
}
|
||||
})
|
||||
.join(',\n\t\t\t')
|
||||
: '';
|
||||
|
||||
const credentialsProperty =
|
||||
credentials.length > 0 ? `credentials: [\n\t\t\t${credentialsArray}\n\t\t],` : '';
|
||||
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
${credentialsProperty}
|
||||
properties: [],
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create non-node class
|
||||
function createNonNodeClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
credentials = [
|
||||
{ name: 'ExternalApi', required: true }
|
||||
];
|
||||
}`;
|
||||
}
|
||||
|
||||
// Helper function to create non-INodeType class
|
||||
function createNonINodeTypeClass(): string {
|
||||
return `
|
||||
export class NotANode {
|
||||
description = {
|
||||
displayName: 'Not A Node',
|
||||
credentials: [
|
||||
{ name: 'ExternalApi', required: true }
|
||||
]
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
function setupMockFileSystem() {
|
||||
mockFindPackageJson.mockReturnValue('/tmp/package.json');
|
||||
mockReadPackageJsonCredentials.mockReturnValue(
|
||||
new Set(['myApiCredential', 'anotherApiCredential']),
|
||||
);
|
||||
}
|
||||
|
||||
setupMockFileSystem();
|
||||
|
||||
ruleTester.run('no-credential-reuse', NoCredentialReuseRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'node using allowed credential (object form) from same package',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([{ name: 'myApiCredential', required: true }]),
|
||||
},
|
||||
{
|
||||
name: 'node using allowed credential (string form) from same package',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(['myApiCredential']),
|
||||
},
|
||||
{
|
||||
name: 'node using multiple allowed credentials (mixed forms)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(['myApiCredential', { name: 'anotherApiCredential', required: false }]),
|
||||
},
|
||||
{
|
||||
name: 'node without credentials array',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(),
|
||||
},
|
||||
{
|
||||
name: 'non-node file ignored',
|
||||
filename: '/tmp/regular-file.ts',
|
||||
code: createNonNodeClass(),
|
||||
},
|
||||
{
|
||||
name: 'non-INodeType class ignored',
|
||||
filename: nodeFilePath,
|
||||
code: createNonINodeTypeClass(),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'SECURITY: node using credential not in package (object form)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([{ name: 'ExternalApi', required: true }]),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: createExpectedNodeCode([{ name: 'myApiCredential', required: true }]),
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: createExpectedNodeCode([{ name: 'anotherApiCredential', required: true }]),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: node using credential not in package (string form)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode(['ExternalApi']),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: createExpectedNodeCode(['myApiCredential']),
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: createExpectedNodeCode(['anotherApiCredential']),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: node using mix of allowed and disallowed credentials (mixed forms)',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([
|
||||
'myApiCredential',
|
||||
{ name: 'ExternalApi', required: true },
|
||||
'AnotherExternalApi',
|
||||
]),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: "myApiCredential",
|
||||
required: true,
|
||||
},
|
||||
'AnotherExternalApi'
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: "anotherApiCredential",
|
||||
required: true,
|
||||
},
|
||||
'AnotherExternalApi'
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'AnotherExternalApi' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: 'ExternalApi',
|
||||
required: true,
|
||||
},
|
||||
"myApiCredential"
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
'myApiCredential',
|
||||
{
|
||||
name: 'ExternalApi',
|
||||
required: true,
|
||||
},
|
||||
"anotherApiCredential"
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'node using multiple disallowed credentials',
|
||||
filename: nodeFilePath,
|
||||
code: createNodeCode([
|
||||
{ name: 'ExternalApi1', required: true },
|
||||
{ name: 'ExternalApi2', required: false },
|
||||
]),
|
||||
errors: [
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi1' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: "myApiCredential",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'ExternalApi2',
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: "anotherApiCredential",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'ExternalApi2',
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: { credentialName: 'ExternalApi2' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'myApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'ExternalApi1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "myApiCredential",
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
{
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: 'anotherApiCredential' },
|
||||
output: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
credentials: [
|
||||
{
|
||||
name: 'ExternalApi1',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "anotherApiCredential",
|
||||
required: false,
|
||||
}
|
||||
],
|
||||
properties: [],
|
||||
};
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findArrayLiteralProperty,
|
||||
extractCredentialNameFromArray,
|
||||
findPackageJson,
|
||||
readPackageJsonCredentials,
|
||||
isFileType,
|
||||
findSimilarStrings,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const NoCredentialReuseRule = createRule({
|
||||
name: 'no-credential-reuse',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Prevent credential re-use security issues by ensuring nodes only reference credentials from the same package',
|
||||
},
|
||||
messages: {
|
||||
didYouMean: "Did you mean '{{ suggestedName }}'?",
|
||||
useAvailable: "Use available credential '{{ suggestedName }}'",
|
||||
credentialNotInPackage:
|
||||
'SECURITY: Node references credential "{{ credentialName }}" which is not defined in this package. This creates a security risk as it attempts to reuse credentials from other packages. Nodes can only use credentials from the same package as listed in package.json n8n.credentials field.',
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!isFileType(context.filename, '.node.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let packageCredentials: Set<string> | null = null;
|
||||
|
||||
const loadPackageCredentials = (): Set<string> => {
|
||||
if (packageCredentials !== null) {
|
||||
return packageCredentials;
|
||||
}
|
||||
|
||||
const packageJsonPath = findPackageJson(context.filename);
|
||||
if (!packageJsonPath) {
|
||||
packageCredentials = new Set();
|
||||
return packageCredentials;
|
||||
}
|
||||
|
||||
packageCredentials = readPackageJsonCredentials(packageJsonPath);
|
||||
return packageCredentials;
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isNodeTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (
|
||||
!descriptionProperty?.value ||
|
||||
descriptionProperty.value.type !== TSESTree.AST_NODE_TYPES.ObjectExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const credentialsArray = findArrayLiteralProperty(descriptionProperty.value, 'credentials');
|
||||
if (!credentialsArray) {
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedCredentials = loadPackageCredentials();
|
||||
|
||||
credentialsArray.elements.forEach((element) => {
|
||||
const credentialInfo = extractCredentialNameFromArray(element);
|
||||
if (credentialInfo && !allowedCredentials.has(credentialInfo.name)) {
|
||||
const similarCredentials = findSimilarStrings(credentialInfo.name, allowedCredentials);
|
||||
const suggestions: ReportSuggestionArray<
|
||||
'didYouMean' | 'useAvailable' | 'credentialNotInPackage'
|
||||
> = [];
|
||||
|
||||
for (const similarName of similarCredentials) {
|
||||
suggestions.push({
|
||||
messageId: 'didYouMean',
|
||||
data: { suggestedName: similarName },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(credentialInfo.node, `"${similarName}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (suggestions.length === 0 && allowedCredentials.size > 0) {
|
||||
const availableCredentials = Array.from(allowedCredentials).slice(0, 3);
|
||||
for (const availableName of availableCredentials) {
|
||||
suggestions.push({
|
||||
messageId: 'useAvailable',
|
||||
data: { suggestedName: availableName },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(credentialInfo.node, `"${availableName}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: credentialInfo.node,
|
||||
messageId: 'credentialNotInPackage',
|
||||
data: {
|
||||
credentialName: credentialInfo.name,
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+187
@@ -0,0 +1,187 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoDeprecatedWorkflowFunctionsRule } from './no-deprecated-workflow-functions.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-deprecated-workflow-functions', NoDeprecatedWorkflowFunctionsRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'using recommended functions and types',
|
||||
code: `
|
||||
import { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
const requestOptions: IHttpRequestOptions = {
|
||||
method: 'GET',
|
||||
url: 'https://example.com',
|
||||
};
|
||||
|
||||
const response1 = await this.helpers.httpRequest(requestOptions);
|
||||
const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oAuth2Api', {
|
||||
method: 'POST',
|
||||
url: 'https://api.example.com/data',
|
||||
});`,
|
||||
},
|
||||
{
|
||||
name: 'functions with similar names should not trigger',
|
||||
code: `
|
||||
import { request } from 'axios';
|
||||
|
||||
const result = await this.helpers.requestSomething();
|
||||
const response = await request('https://api.example.com');
|
||||
const config = { request: 'some value' };
|
||||
|
||||
// Other objects with helpers property should not trigger
|
||||
const otherObject = {
|
||||
helpers: {
|
||||
request: () => 'not n8n',
|
||||
requestWithAuthentication: () => 'not n8n'
|
||||
}
|
||||
};
|
||||
const result2 = otherObject.helpers.request();`,
|
||||
},
|
||||
{
|
||||
name: 'types with same name from other modules should not trigger',
|
||||
code: `
|
||||
import { IRequestOptions } from 'some-other-package';
|
||||
|
||||
function test(options: IRequestOptions) {
|
||||
return options.url;
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'deprecated request functions',
|
||||
code: `
|
||||
const response1 = await this.helpers.request('https://example.com/1');
|
||||
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
output: `
|
||||
const response1 = await this.helpers.httpRequest('https://example.com/1');
|
||||
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: {
|
||||
functionName: 'requestWithAuthentication',
|
||||
replacement: 'httpRequestWithAuthentication',
|
||||
},
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: {
|
||||
functionName: 'requestWithAuthentication',
|
||||
replacement: 'httpRequestWithAuthentication',
|
||||
},
|
||||
output: `
|
||||
const response1 = await this.helpers.request('https://example.com/1');
|
||||
const response2 = await this.helpers.httpRequestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.requestOAuth2.call(this, 'google', options);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName: 'requestOAuth2', replacement: 'httpRequestWithAuthentication' },
|
||||
output: `
|
||||
const response1 = await this.helpers.request('https://example.com/1');
|
||||
const response2 = await this.helpers.requestWithAuthentication.call(this, 'oauth', options);
|
||||
const response3 = await this.helpers.httpRequestWithAuthentication.call(this, 'google', options);`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'deprecated types',
|
||||
code: `
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
return this.helpers.request(options);
|
||||
}`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'deprecatedType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
output: `
|
||||
import { IHttpRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
return this.helpers.request(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName: 'IRequestOptions', replacement: 'IHttpRequestOptions' },
|
||||
output: `
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IHttpRequestOptions): Promise<any> {
|
||||
return this.helpers.request(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedRequestFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName: 'request', replacement: 'httpRequest' },
|
||||
output: `
|
||||
import { IRequestOptions } from 'n8n-workflow';
|
||||
|
||||
function makeRequest(options: IRequestOptions): Promise<any> {
|
||||
return this.helpers.httpRequest(options);
|
||||
}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'functions without replacement',
|
||||
code: `
|
||||
const result = await this.helpers.copyBinaryFile();
|
||||
return this.helpers.prepareOutputData([{ json: response }]);`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'deprecatedWithoutReplacement',
|
||||
data: { functionName: 'copyBinaryFile' },
|
||||
},
|
||||
{
|
||||
messageId: 'deprecatedWithoutReplacement',
|
||||
data: { functionName: 'prepareOutputData' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
const DEPRECATED_FUNCTIONS = {
|
||||
request: 'httpRequest',
|
||||
requestWithAuthentication: 'httpRequestWithAuthentication',
|
||||
requestOAuth1: 'httpRequestWithAuthentication',
|
||||
requestOAuth2: 'httpRequestWithAuthentication',
|
||||
copyBinaryFile: null,
|
||||
prepareOutputData: null,
|
||||
} as const;
|
||||
|
||||
const DEPRECATED_TYPES = {
|
||||
IRequestOptions: 'IHttpRequestOptions',
|
||||
} as const;
|
||||
|
||||
function isDeprecatedFunctionName(name: string): name is keyof typeof DEPRECATED_FUNCTIONS {
|
||||
return name in DEPRECATED_FUNCTIONS;
|
||||
}
|
||||
|
||||
function isDeprecatedTypeName(name: string): name is keyof typeof DEPRECATED_TYPES {
|
||||
return name in DEPRECATED_TYPES;
|
||||
}
|
||||
|
||||
export const NoDeprecatedWorkflowFunctionsRule = createRule({
|
||||
name: 'no-deprecated-workflow-functions',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow usage of deprecated functions and types from n8n-workflow package',
|
||||
},
|
||||
messages: {
|
||||
deprecatedRequestFunction:
|
||||
"'{{ functionName }}' is deprecated. Use '{{ replacement }}' instead for better authentication support and consistency.",
|
||||
deprecatedFunction: "'{{ functionName }}' is deprecated and should be avoided. {{ message }}",
|
||||
deprecatedType: "'{{ typeName }}' is deprecated. Use '{{ replacement }}' instead.",
|
||||
deprecatedWithoutReplacement:
|
||||
"'{{ functionName }}' is deprecated and should be removed or replaced with alternative implementation.",
|
||||
suggestReplaceFunction: "Replace '{{ functionName }}' with '{{ replacement }}'",
|
||||
suggestReplaceType: "Replace '{{ typeName }}' with '{{ replacement }}'",
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
const n8nWorkflowTypes = new Set<string>();
|
||||
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
if (node.source.value === 'n8n-workflow') {
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (
|
||||
specifier.type === AST_NODE_TYPES.ImportSpecifier &&
|
||||
specifier.imported.type === AST_NODE_TYPES.Identifier
|
||||
) {
|
||||
n8nWorkflowTypes.add(specifier.local.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
MemberExpression(node) {
|
||||
if (
|
||||
node.property.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedFunctionName(node.property.name)
|
||||
) {
|
||||
if (!isThisHelpersAccess(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const functionName = node.property.name;
|
||||
const replacement = DEPRECATED_FUNCTIONS[functionName];
|
||||
|
||||
if (replacement) {
|
||||
const messageId = functionName.includes('request')
|
||||
? 'deprecatedRequestFunction'
|
||||
: 'deprecatedFunction';
|
||||
|
||||
context.report({
|
||||
node: node.property,
|
||||
messageId,
|
||||
data: {
|
||||
functionName,
|
||||
replacement,
|
||||
message: getDeprecationMessage(functionName),
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceFunction',
|
||||
data: { functionName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.property, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
} else {
|
||||
context.report({
|
||||
node: node.property,
|
||||
messageId: 'deprecatedWithoutReplacement',
|
||||
data: {
|
||||
functionName,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
TSTypeReference(node) {
|
||||
if (
|
||||
node.typeName.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedTypeName(node.typeName.name) &&
|
||||
n8nWorkflowTypes.has(node.typeName.name)
|
||||
) {
|
||||
const typeName = node.typeName.name;
|
||||
const replacement = DEPRECATED_TYPES[typeName];
|
||||
|
||||
context.report({
|
||||
node: node.typeName,
|
||||
messageId: 'deprecatedType',
|
||||
data: {
|
||||
typeName,
|
||||
replacement,
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.typeName, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
ImportSpecifier(node) {
|
||||
// Check if this import is from n8n-workflow by looking at the parent ImportDeclaration
|
||||
const importDeclaration = node.parent;
|
||||
if (
|
||||
importDeclaration?.type === AST_NODE_TYPES.ImportDeclaration &&
|
||||
importDeclaration.source.value === 'n8n-workflow' &&
|
||||
node.imported.type === AST_NODE_TYPES.Identifier &&
|
||||
isDeprecatedTypeName(node.imported.name)
|
||||
) {
|
||||
const typeName = node.imported.name;
|
||||
const replacement = DEPRECATED_TYPES[typeName];
|
||||
|
||||
context.report({
|
||||
node: node.imported,
|
||||
messageId: 'deprecatedType',
|
||||
data: {
|
||||
typeName,
|
||||
replacement,
|
||||
},
|
||||
suggest: [
|
||||
{
|
||||
messageId: 'suggestReplaceType',
|
||||
data: { typeName, replacement },
|
||||
fix: (fixer) => fixer.replaceText(node.imported, replacement),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Check if the MemberExpression follows the this.helpers.* pattern
|
||||
*/
|
||||
function isThisHelpersAccess(node: TSESTree.MemberExpression): boolean {
|
||||
if (node.object?.type === AST_NODE_TYPES.MemberExpression) {
|
||||
const outerObject = node.object;
|
||||
return (
|
||||
outerObject.object?.type === AST_NODE_TYPES.ThisExpression &&
|
||||
outerObject.property?.type === AST_NODE_TYPES.Identifier &&
|
||||
outerObject.property.name === 'helpers'
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDeprecationMessage(functionName: string): string {
|
||||
switch (functionName) {
|
||||
case 'request':
|
||||
return 'Use httpRequest for better type safety and consistency.';
|
||||
case 'requestWithAuthentication':
|
||||
case 'requestOAuth1':
|
||||
case 'requestOAuth2':
|
||||
return 'Use httpRequestWithAuthentication which provides unified authentication handling.';
|
||||
case 'copyBinaryFile':
|
||||
return 'This function has been removed. Handle binary data directly.';
|
||||
case 'prepareOutputData':
|
||||
return 'This function is deprecated. Return data directly from execute method.';
|
||||
default:
|
||||
return 'This function is deprecated and should be avoided.';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoRestrictedGlobalsRule } from './no-restricted-globals.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-restricted-globals', NoRestrictedGlobalsRule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'const result = someFunction();',
|
||||
},
|
||||
{
|
||||
code: 'window.setTimeout(() => {}, 1000);',
|
||||
},
|
||||
{
|
||||
code: 'const obj = { global: "allowed" };',
|
||||
},
|
||||
{
|
||||
code: 'function process() { return "allowed"; }',
|
||||
},
|
||||
{
|
||||
code: 'console.clearInterval;',
|
||||
},
|
||||
{
|
||||
code: 'const obj = { __dirname: "allowed", Buffer: "allowed", require: "allowed" };',
|
||||
},
|
||||
{
|
||||
code: 'function globalThis() { return "allowed"; }',
|
||||
},
|
||||
{
|
||||
code: 'const helper = require("./helper");',
|
||||
},
|
||||
{
|
||||
name: 'variable declarations should be allowed',
|
||||
code: 'const process = "my-process"; let global = "my-global";',
|
||||
},
|
||||
{
|
||||
name: 'function parameters should be allowed',
|
||||
code: 'function test(process, global, setTimeout) { return process; }',
|
||||
},
|
||||
{
|
||||
name: 'arrow function parameters should be allowed',
|
||||
code: 'const fn = (process, global) => process + global;',
|
||||
},
|
||||
{
|
||||
name: 'destructuring should be allowed',
|
||||
code: 'const { process, global } = someObject; const [setTimeout] = someArray;',
|
||||
},
|
||||
{
|
||||
name: 'class methods should be allowed',
|
||||
code: 'class MyClass { process() {} global = "value"; }',
|
||||
},
|
||||
{
|
||||
name: 'import should be allowed',
|
||||
code: 'import { process } from "./utils";',
|
||||
},
|
||||
{
|
||||
name: 'function expressions should be allowed',
|
||||
code: 'const fn = function process() {}; const fn2 = function global() {};',
|
||||
},
|
||||
{
|
||||
name: 'locally declared variables should not trigger false positives',
|
||||
code: `
|
||||
const process = require('process');
|
||||
const global = {};
|
||||
const setTimeout = () => {};
|
||||
function clearInterval() {}
|
||||
let setInterval;
|
||||
var __dirname = '/path';
|
||||
const __filename = 'file.js';
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const pid = process.pid;',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'process' } }],
|
||||
},
|
||||
{
|
||||
code: 'global.myVar = "test";',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'global' } }],
|
||||
},
|
||||
{
|
||||
code: 'setTimeout(() => {}, 1000);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setTimeout' } }],
|
||||
},
|
||||
{
|
||||
code: 'clearInterval(timer);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearInterval' } }],
|
||||
},
|
||||
{
|
||||
code: 'clearTimeout(timer);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearTimeout' } }],
|
||||
},
|
||||
{
|
||||
code: 'setInterval(() => {}, 1000);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setInterval' } }],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fn = () => {
|
||||
process.exit(0);
|
||||
global.something = true;
|
||||
};`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedGlobal', data: { name: 'process' } },
|
||||
{ messageId: 'restrictedGlobal', data: { name: 'global' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: __dirname usage',
|
||||
code: 'const currentDir = __dirname;',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: '__dirname' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: __filename usage',
|
||||
code: 'console.log(__filename);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: '__filename' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: globalThis usage',
|
||||
code: 'globalThis.myVar = "test";',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'globalThis' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: setImmediate usage',
|
||||
code: 'setImmediate(() => {});',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'setImmediate' } }],
|
||||
},
|
||||
{
|
||||
name: 'SECURITY: clearImmediate usage',
|
||||
code: 'clearImmediate(immediate);',
|
||||
errors: [{ messageId: 'restrictedGlobal', data: { name: 'clearImmediate' } }],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
import type { TSESLint } from '@typescript-eslint/utils';
|
||||
|
||||
import { createRule } from '../utils/index.js';
|
||||
|
||||
const restrictedGlobals = [
|
||||
'clearInterval',
|
||||
'clearTimeout',
|
||||
'global',
|
||||
'globalThis',
|
||||
'process',
|
||||
'setInterval',
|
||||
'setTimeout',
|
||||
'setImmediate',
|
||||
'clearImmediate',
|
||||
'__dirname',
|
||||
'__filename',
|
||||
];
|
||||
|
||||
export const NoRestrictedGlobalsRule = createRule({
|
||||
name: 'no-restricted-globals',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow usage of restricted global variables in community nodes.',
|
||||
},
|
||||
messages: {
|
||||
restrictedGlobal: "Use of restricted global '{{ name }}' is not allowed",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
function checkReference(ref: TSESLint.Scope.Reference, name: string) {
|
||||
const { parent } = ref.identifier;
|
||||
|
||||
// Skip property access (like console.process - we want process.exit but not obj.process)
|
||||
if (
|
||||
parent?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
|
||||
parent.property === ref.identifier &&
|
||||
!parent.computed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: ref.identifier,
|
||||
messageId: 'restrictedGlobal',
|
||||
data: { name },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
Program() {
|
||||
const globalScope = context.sourceCode.getScope(context.sourceCode.ast);
|
||||
|
||||
const allReferences = [
|
||||
...globalScope.variables
|
||||
.filter(
|
||||
(variable) => restrictedGlobals.includes(variable.name) && variable.defs.length === 0, // No definitions means it's a global
|
||||
)
|
||||
.flatMap((variable) =>
|
||||
variable.references.map((ref) => ({ ref, name: variable.name })),
|
||||
),
|
||||
...globalScope.through
|
||||
.filter((ref) => restrictedGlobals.includes(ref.identifier.name))
|
||||
.map((ref) => ({ ref, name: ref.identifier.name })),
|
||||
];
|
||||
|
||||
allReferences.forEach(({ ref, name }) => checkReference(ref, name));
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NoRestrictedImportsRule } from './no-restricted-imports.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('no-restricted-imports', NoRestrictedImportsRule, {
|
||||
valid: [
|
||||
{
|
||||
code: 'import { WorkflowExecuteMode } from "n8n-workflow";',
|
||||
},
|
||||
{
|
||||
code: 'import _ from "lodash";',
|
||||
},
|
||||
{
|
||||
code: 'import moment from "moment";',
|
||||
},
|
||||
{
|
||||
code: 'import pLimit from "p-limit";',
|
||||
},
|
||||
{
|
||||
code: 'import { DateTime } from "luxon";',
|
||||
},
|
||||
{
|
||||
code: 'import { z } from "zod";',
|
||||
},
|
||||
{
|
||||
code: 'import crypto from "crypto";',
|
||||
},
|
||||
{
|
||||
code: 'import crypto from "node:crypto";',
|
||||
},
|
||||
{
|
||||
code: 'import { helper } from "./helper";',
|
||||
},
|
||||
{
|
||||
code: 'import { utils } from "../utils";',
|
||||
},
|
||||
{
|
||||
code: 'const helper = require("./helper");',
|
||||
},
|
||||
{
|
||||
code: 'const utils = require("../utils");',
|
||||
},
|
||||
{
|
||||
code: 'const _ = require("lodash");',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("lodash");',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("./helper");',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("../utils");',
|
||||
},
|
||||
{
|
||||
code: 'const workflow = await import("n8n-workflow");',
|
||||
},
|
||||
{
|
||||
code: 'import("lodash").then((_) => {});',
|
||||
},
|
||||
{
|
||||
code: 'const helper = await import("./helper");',
|
||||
},
|
||||
{
|
||||
code: 'import("../utils").then((utils) => {});',
|
||||
},
|
||||
{
|
||||
code: 'import(`lodash`).then((_) => {});',
|
||||
},
|
||||
{
|
||||
code: 'require(`./helper`);',
|
||||
},
|
||||
{
|
||||
code: 'require.resolve(`n8n-workflow`);',
|
||||
},
|
||||
{
|
||||
code: 'const workflow = await import(`n8n-workflow`);',
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: 'import fs from "fs";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'import path from "path";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'import express from "express";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'import axios from "axios";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: 'axios' } }],
|
||||
},
|
||||
{
|
||||
code: 'import { Client } from "@elastic/elasticsearch";',
|
||||
errors: [{ messageId: 'restrictedImport', data: { modulePath: '@elastic/elasticsearch' } }],
|
||||
},
|
||||
{
|
||||
code: 'const fs = require("fs");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'const path = require("path");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'const express = require("express");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("fs");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'require.resolve("express");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'const resolved = require.resolve("axios");',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'axios' } }],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { WorkflowExecuteMode } from "n8n-workflow";
|
||||
import { supplyModel } from "@n8n/ai-node-sdk";`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedImport', data: { modulePath: 'fs' } },
|
||||
{ messageId: 'restrictedImport', data: { modulePath: 'path' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fs = require("fs");
|
||||
const express = require("express");
|
||||
const lodash = require("lodash");`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedRequire', data: { modulePath: 'fs' } },
|
||||
{ messageId: 'restrictedRequire', data: { modulePath: 'express' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'const fs = await import("fs");',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'fs' } }],
|
||||
},
|
||||
{
|
||||
code: 'import("path").then((path) => {});',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'const express = await import("express");',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'const path = require(`path`);',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'path' } }],
|
||||
},
|
||||
{
|
||||
code: 'require.resolve(`express`);',
|
||||
errors: [{ messageId: 'restrictedRequire', data: { modulePath: 'express' } }],
|
||||
},
|
||||
{
|
||||
code: 'const axios = await import(`axios`);',
|
||||
errors: [{ messageId: 'restrictedDynamicImport', data: { modulePath: 'axios' } }],
|
||||
},
|
||||
{
|
||||
code: `
|
||||
const fs = await import("fs");
|
||||
import("axios").then((axios) => {});
|
||||
const workflow = await import("n8n-workflow");`,
|
||||
errors: [
|
||||
{ messageId: 'restrictedDynamicImport', data: { modulePath: 'fs' } },
|
||||
{ messageId: 'restrictedDynamicImport', data: { modulePath: 'axios' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
getModulePath,
|
||||
isDirectRequireCall,
|
||||
isRequireMemberCall,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
const allowedModules = [
|
||||
'n8n-workflow',
|
||||
'ai-node-sdk',
|
||||
'lodash',
|
||||
'moment',
|
||||
'p-limit',
|
||||
'luxon',
|
||||
'zod',
|
||||
'crypto',
|
||||
'node:crypto',
|
||||
'@n8n/ai-node-sdk',
|
||||
];
|
||||
|
||||
const isModuleAllowed = (modulePath: string): boolean => {
|
||||
if (modulePath.startsWith('./') || modulePath.startsWith('../')) return true;
|
||||
|
||||
const moduleName = modulePath.startsWith('@')
|
||||
? modulePath.split('/').slice(0, 2).join('/')
|
||||
: modulePath.split('/')[0];
|
||||
if (!moduleName) return true;
|
||||
return allowedModules.includes(moduleName);
|
||||
};
|
||||
|
||||
export const NoRestrictedImportsRule = createRule({
|
||||
name: 'no-restricted-imports',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Disallow usage of restricted imports in community nodes.',
|
||||
},
|
||||
messages: {
|
||||
restrictedImport:
|
||||
"Import of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
|
||||
restrictedRequire:
|
||||
"Require of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
|
||||
restrictedDynamicImport:
|
||||
"Dynamic import of '{{ modulePath }}' is not allowed. n8n Cloud does not allow community nodes with dependencies.",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
const modulePath = getModulePath(node.source);
|
||||
if (modulePath && !isModuleAllowed(modulePath)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'restrictedImport',
|
||||
data: {
|
||||
modulePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
ImportExpression(node) {
|
||||
const modulePath = getModulePath(node.source);
|
||||
if (modulePath && !isModuleAllowed(modulePath)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'restrictedDynamicImport',
|
||||
data: {
|
||||
modulePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
CallExpression(node) {
|
||||
if (isDirectRequireCall(node) || isRequireMemberCall(node)) {
|
||||
const modulePath = getModulePath(node.arguments[0] ?? null);
|
||||
if (modulePath && !isModuleAllowed(modulePath)) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'restrictedRequire',
|
||||
data: {
|
||||
modulePath,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { NodeUsableAsToolRule } from './node-usable-as-tool.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
function createNodeCode(
|
||||
usableAsTool?: boolean | 'missing',
|
||||
hasDescription: boolean = true,
|
||||
): string {
|
||||
let usableAsToolProperty = '';
|
||||
if (usableAsTool === true) {
|
||||
usableAsToolProperty = ',\n\t\tusableAsTool: true';
|
||||
} else if (usableAsTool === false) {
|
||||
usableAsToolProperty = ',\n\t\tusableAsTool: false';
|
||||
}
|
||||
|
||||
if (!hasDescription) {
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
displayName = 'Test Node';
|
||||
}`;
|
||||
}
|
||||
|
||||
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: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: []${usableAsToolProperty},
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNonNodeClass(): string {
|
||||
return `
|
||||
export class RegularClass {
|
||||
someProperty = 'value';
|
||||
}`;
|
||||
}
|
||||
|
||||
function createNodeCodeWithOutputsInputs(
|
||||
outputs: string,
|
||||
inputs: string,
|
||||
includeUsableAsTool = false,
|
||||
): string {
|
||||
const usableAsToolLine = includeUsableAsTool ? '\n\t\tusableAsTool: true,' : '';
|
||||
return `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
import { NodeConnectionTypes } 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: ${inputs},
|
||||
outputs: ${outputs},
|
||||
properties: [],${usableAsToolLine}
|
||||
};
|
||||
}`;
|
||||
}
|
||||
|
||||
ruleTester.run('node-usable-as-tool', NodeUsableAsToolRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'node with usableAsTool set to true',
|
||||
code: createNodeCode(true),
|
||||
},
|
||||
{
|
||||
name: 'class that does not implement INodeType',
|
||||
code: createNonNodeClass(),
|
||||
},
|
||||
{
|
||||
name: 'node with usableAsTool set to false',
|
||||
code: createNodeCode(false),
|
||||
},
|
||||
{
|
||||
name: 'node without description property',
|
||||
code: createNodeCode(undefined, false),
|
||||
},
|
||||
{
|
||||
name: 'AI-only node: NodeConnectionTypes non-Main output and empty inputs skips usableAsTool check',
|
||||
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', '[]'),
|
||||
},
|
||||
{
|
||||
name: 'AI-only node: multiple non-Main NodeConnectionTypes outputs and empty inputs skips usableAsTool check',
|
||||
code: createNodeCodeWithOutputsInputs(
|
||||
'[NodeConnectionTypes.AiAgent, NodeConnectionTypes.AiTool]',
|
||||
'[]',
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'AI-only node: non-main string literal output and empty inputs skips usableAsTool check',
|
||||
code: createNodeCodeWithOutputsInputs("['ai_agent']", '[]'),
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'node missing usableAsTool property',
|
||||
code: createNodeCode('missing'),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCode(true),
|
||||
},
|
||||
{
|
||||
name: 'NodeConnectionTypes.Main output with empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]'),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.Main]', '[]', true),
|
||||
},
|
||||
{
|
||||
name: 'main string literal output with empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs("['main']", '[]'),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs("['main']", '[]', true),
|
||||
},
|
||||
{
|
||||
name: 'non-Main output with non-empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']"),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs('[NodeConnectionTypes.AiAgent]', "['main']", true),
|
||||
},
|
||||
{
|
||||
name: 'non-main string literal output with non-empty inputs does not skip check',
|
||||
code: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']"),
|
||||
errors: [{ messageId: 'missingUsableAsTool' }],
|
||||
output: createNodeCodeWithOutputsInputs("['ai_agent']", "['main']", true),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { TSESTree } from '@typescript-eslint/types';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const NodeUsableAsToolRule = createRule({
|
||||
name: 'node-usable-as-tool',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Ensure node classes have usableAsTool property',
|
||||
},
|
||||
messages: {
|
||||
missingUsableAsTool:
|
||||
'Node class should have usableAsTool property. When in doubt, set it to true.',
|
||||
},
|
||||
fixable: 'code',
|
||||
schema: [],
|
||||
},
|
||||
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 !== TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const usableAsToolProperty = findObjectProperty(descriptionValue, 'usableAsTool');
|
||||
const outputsProperty = findObjectProperty(descriptionValue, 'outputs');
|
||||
const inputsProperty = findObjectProperty(descriptionValue, 'inputs');
|
||||
if (
|
||||
outputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression &&
|
||||
inputsProperty?.value?.type === TSESTree.AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
const isAiOutput = outputsProperty?.value?.elements?.some((element) => {
|
||||
const isAiOutputEnum =
|
||||
element?.type === TSESTree.AST_NODE_TYPES.MemberExpression &&
|
||||
element?.object?.type === TSESTree.AST_NODE_TYPES.Identifier &&
|
||||
element?.object?.name === 'NodeConnectionTypes' &&
|
||||
element?.property?.type === TSESTree.AST_NODE_TYPES.Identifier &&
|
||||
element?.property?.name !== 'Main';
|
||||
const isAiOutputLiteral =
|
||||
element?.type === TSESTree.AST_NODE_TYPES.Literal && element?.value !== 'main';
|
||||
return isAiOutputEnum || isAiOutputLiteral;
|
||||
});
|
||||
const isEmptyInputs = inputsProperty?.value?.elements?.length === 0;
|
||||
if (isAiOutput && isEmptyInputs) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!usableAsToolProperty) {
|
||||
context.report({
|
||||
node,
|
||||
messageId: 'missingUsableAsTool',
|
||||
fix(fixer) {
|
||||
if (descriptionValue?.type === TSESTree.AST_NODE_TYPES.ObjectExpression) {
|
||||
const properties = descriptionValue.properties;
|
||||
if (properties.length === 0) {
|
||||
const openBrace = descriptionValue.range[0] + 1;
|
||||
return fixer.insertTextAfterRange(
|
||||
[openBrace, openBrace],
|
||||
'\n\t\tusableAsTool: true,',
|
||||
);
|
||||
} else {
|
||||
const lastProperty = properties.at(-1);
|
||||
if (lastProperty) {
|
||||
return fixer.insertTextAfter(lastProperty, ',\n\t\tusableAsTool: true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { PackageNameConventionRule } from './package-name-convention.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('package-name-convention', PackageNameConventionRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'valid unscoped package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'valid unscoped package name with dashes',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-my-service", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'valid scoped package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@mycompany/n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'valid scoped package name with dashes',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@author/n8n-nodes-service", "version": "1.0.0" }',
|
||||
},
|
||||
{
|
||||
name: 'object without name property',
|
||||
filename: 'package.json',
|
||||
code: '{ "version": "1.0.0", "description": "test" }',
|
||||
},
|
||||
{
|
||||
name: 'non-package.json file ignored',
|
||||
filename: 'some-config.json',
|
||||
code: '{ "name": "my-config", "type": "config" }',
|
||||
},
|
||||
{
|
||||
name: 'nested name fields should be ignored - only top-level name matters',
|
||||
filename: 'package.json',
|
||||
code: `{
|
||||
"name": "n8n-nodes-example",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"name": "invalid-nested-name"
|
||||
},
|
||||
"scripts": {
|
||||
"name": "another-invalid-name"
|
||||
},
|
||||
"author": {
|
||||
"name": "John Doe"
|
||||
}
|
||||
}`,
|
||||
},
|
||||
{
|
||||
name: 'deeply nested name fields should be ignored',
|
||||
filename: 'package.json',
|
||||
code: `{
|
||||
"name": "@author/n8n-nodes-service",
|
||||
"version": "1.0.0",
|
||||
"config": {
|
||||
"nested": {
|
||||
"deeply": {
|
||||
"name": "very-invalid-name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/user/repo",
|
||||
"directory": {
|
||||
"name": "bad-name"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'invalid package name - generic',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "my-package", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'my-package' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: 'n8n-nodes-my-package' },
|
||||
output: '{ "name": "n8n-nodes-my-package", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid package name - missing nodes',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-example", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'n8n-example' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: 'n8n-nodes-example' },
|
||||
output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid scoped package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@company/example-nodes", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: '@company/example-nodes' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: '@company/n8n-nodes-example' },
|
||||
output: '{ "name": "@company/n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'invalid package name - wrong order',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "nodes-n8n-example", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'nodes-n8n-example' },
|
||||
suggestions: [
|
||||
{
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName: 'n8n-nodes-example' },
|
||||
output: '{ "name": "n8n-nodes-example", "version": "1.0.0" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'empty package name',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: '' },
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'incomplete package name with missing suffix',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "n8n-nodes-", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: 'n8n-nodes-' },
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'incomplete scoped package name with missing suffix',
|
||||
filename: 'package.json',
|
||||
code: '{ "name": "@company/n8n-nodes-", "version": "1.0.0" }',
|
||||
errors: [
|
||||
{
|
||||
messageId: 'invalidPackageName',
|
||||
data: { packageName: '@company/n8n-nodes-' },
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
import type { ReportSuggestionArray } from '@typescript-eslint/utils/ts-eslint';
|
||||
|
||||
import { createRule, findJsonProperty } from '../utils/index.js';
|
||||
|
||||
export const PackageNameConventionRule = createRule({
|
||||
name: 'package-name-convention',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce correct package naming convention for n8n community nodes',
|
||||
},
|
||||
messages: {
|
||||
renameTo: "Rename to '{{suggestedName}}'",
|
||||
invalidPackageName:
|
||||
'Package name "{{ packageName }}" must follow the convention "n8n-nodes-[PACKAGE-NAME]" or "@[AUTHOR]/n8n-nodes-[PACKAGE-NAME]"',
|
||||
},
|
||||
schema: [],
|
||||
hasSuggestions: true,
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!context.filename.endsWith('package.json')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
ObjectExpression(node: TSESTree.ObjectExpression) {
|
||||
if (node.parent?.type === AST_NODE_TYPES.Property) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameProperty = findJsonProperty(node, 'name');
|
||||
|
||||
if (!nameProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (nameProperty.value.type !== AST_NODE_TYPES.Literal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const packageName = nameProperty.value.value;
|
||||
const packageNameStr = typeof packageName === 'string' ? packageName : null;
|
||||
|
||||
if (!packageNameStr || !isValidPackageName(packageNameStr)) {
|
||||
const suggestions: ReportSuggestionArray<'invalidPackageName' | 'renameTo'> = [];
|
||||
|
||||
// Generate package name suggestions if we have a valid string
|
||||
if (packageNameStr) {
|
||||
const suggestedNames = generatePackageNameSuggestions(packageNameStr);
|
||||
for (const suggestedName of suggestedNames) {
|
||||
suggestions.push({
|
||||
messageId: 'renameTo',
|
||||
data: { suggestedName },
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(nameProperty.value, `"${suggestedName}"`);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
context.report({
|
||||
node: nameProperty,
|
||||
messageId: 'invalidPackageName',
|
||||
data: {
|
||||
packageName: packageNameStr ?? 'undefined',
|
||||
},
|
||||
suggest: suggestions,
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
function isValidPackageName(name: string): boolean {
|
||||
const unscoped = /^n8n-nodes-.+$/;
|
||||
const scoped = /^@.+\/n8n-nodes-.+$/;
|
||||
return unscoped.test(name) || scoped.test(name);
|
||||
}
|
||||
|
||||
function generatePackageNameSuggestions(invalidName: string): string[] {
|
||||
const cleanName = (name: string) => {
|
||||
return name
|
||||
.replace(/^nodes?-?n8n-?/, '')
|
||||
.replace(/^n8n-/, '')
|
||||
.replace(/^nodes?-?/, '')
|
||||
.replace(/^node-/, '')
|
||||
.replace(/-nodes$/, '');
|
||||
};
|
||||
|
||||
if (invalidName.startsWith('@')) {
|
||||
const [scope, packagePart] = invalidName.split('/');
|
||||
const clean = cleanName(packagePart ?? '');
|
||||
return clean ? [`${scope}/n8n-nodes-${clean}`] : [];
|
||||
}
|
||||
|
||||
const clean = cleanName(invalidName);
|
||||
return clean ? [`n8n-nodes-${clean}`] : [];
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import { RuleTester } from '@typescript-eslint/rule-tester';
|
||||
|
||||
import { ResourceOperationPatternRule } from './resource-operation-pattern.js';
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run('resource-operation-pattern', ResourceOperationPatternRule, {
|
||||
valid: [
|
||||
{
|
||||
name: 'node with resources and operations (good pattern)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Resource',
|
||||
name: 'resource',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'User', value: 'user' },
|
||||
{ name: 'Project', value: 'project' }
|
||||
],
|
||||
default: 'user'
|
||||
},
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'node without operations property',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'API Key',
|
||||
name: 'apiKey',
|
||||
type: 'string',
|
||||
default: ''
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'non-node class ignored',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
export class NotANode {
|
||||
description = {
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
{
|
||||
name: 'node with exactly 5 operations without resources (allowed)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' },
|
||||
{ name: 'List', value: 'list' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
name: 'node with exactly 6 operations without resources (error)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get', value: 'get' },
|
||||
{ name: 'Create', value: 'create' },
|
||||
{ name: 'Update', value: 'update' },
|
||||
{ name: 'Delete', value: 'delete' },
|
||||
{ name: 'List', value: 'list' },
|
||||
{ name: 'Search', value: 'search' }
|
||||
],
|
||||
default: 'get'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'tooManyOperationsWithoutResources',
|
||||
data: { operationCount: '6' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'node with many operations without resources (error)',
|
||||
filename: '/tmp/TestNode.node.ts',
|
||||
code: `
|
||||
import type { INodeType, INodeTypeDescription } from 'n8n-workflow';
|
||||
|
||||
export class TestNode implements INodeType {
|
||||
description: INodeTypeDescription = {
|
||||
displayName: 'Test Node',
|
||||
name: 'testNode',
|
||||
group: ['output'],
|
||||
version: 1,
|
||||
inputs: ['main'],
|
||||
outputs: ['main'],
|
||||
properties: [
|
||||
{
|
||||
displayName: 'Operation',
|
||||
name: 'operation',
|
||||
type: 'options',
|
||||
options: [
|
||||
{ name: 'Get User', value: 'getUser' },
|
||||
{ name: 'Create User', value: 'createUser' },
|
||||
{ name: 'Update User', value: 'updateUser' },
|
||||
{ name: 'Delete User', value: 'deleteUser' },
|
||||
{ name: 'List Users', value: 'listUsers' },
|
||||
{ name: 'Get Project', value: 'getProject' },
|
||||
{ name: 'Create Project', value: 'createProject' },
|
||||
{ name: 'Update Project', value: 'updateProject' }
|
||||
],
|
||||
default: 'getUser'
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
`,
|
||||
errors: [
|
||||
{
|
||||
messageId: 'tooManyOperationsWithoutResources',
|
||||
data: { operationCount: '8' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { TSESTree } from '@typescript-eslint/utils';
|
||||
import { AST_NODE_TYPES } from '@typescript-eslint/utils';
|
||||
|
||||
import {
|
||||
isNodeTypeClass,
|
||||
findClassProperty,
|
||||
findObjectProperty,
|
||||
getStringLiteralValue,
|
||||
isFileType,
|
||||
createRule,
|
||||
} from '../utils/index.js';
|
||||
|
||||
export const ResourceOperationPatternRule = createRule({
|
||||
name: 'resource-operation-pattern',
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'Enforce proper resource/operation pattern for better UX in n8n nodes',
|
||||
},
|
||||
messages: {
|
||||
tooManyOperationsWithoutResources:
|
||||
'Node has {{ operationCount }} operations without resources. Use resources to organize operations when there are more than 5 operations.',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
defaultOptions: [],
|
||||
create(context) {
|
||||
if (!isFileType(context.filename, '.node.ts')) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const analyzeNodeDescription = (descriptionValue: TSESTree.Expression | null): void => {
|
||||
if (!descriptionValue || descriptionValue.type !== AST_NODE_TYPES.ObjectExpression) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesProperty = findObjectProperty(descriptionValue, 'properties');
|
||||
if (
|
||||
!propertiesProperty?.value ||
|
||||
propertiesProperty.value.type !== AST_NODE_TYPES.ArrayExpression
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const propertiesArray = propertiesProperty.value;
|
||||
let hasResources = false;
|
||||
let operationCount = 0;
|
||||
let operationNode: TSESTree.Node | null = null;
|
||||
|
||||
for (const property of propertiesArray.elements) {
|
||||
if (!property || property.type !== AST_NODE_TYPES.ObjectExpression) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameProperty = findObjectProperty(property, 'name');
|
||||
const typeProperty = findObjectProperty(property, 'type');
|
||||
|
||||
const name = nameProperty ? getStringLiteralValue(nameProperty.value) : null;
|
||||
const type = typeProperty ? getStringLiteralValue(typeProperty.value) : null;
|
||||
|
||||
if (!name || !type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (name === 'resource' && type === 'options') {
|
||||
hasResources = true;
|
||||
}
|
||||
|
||||
if (name === 'operation' && type === 'options') {
|
||||
operationNode = property;
|
||||
const optionsProperty = findObjectProperty(property, 'options');
|
||||
if (optionsProperty?.value?.type === AST_NODE_TYPES.ArrayExpression) {
|
||||
operationCount = optionsProperty.value.elements.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (operationCount > 5 && !hasResources && operationNode) {
|
||||
context.report({
|
||||
node: operationNode,
|
||||
messageId: 'tooManyOperationsWithoutResources',
|
||||
data: {
|
||||
operationCount: operationCount.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
ClassDeclaration(node) {
|
||||
if (!isNodeTypeClass(node)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const descriptionProperty = findClassProperty(node, 'description');
|
||||
if (!descriptionProperty) {
|
||||
return;
|
||||
}
|
||||
|
||||
analyzeNodeDescription(descriptionProperty.value);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user