diff --git a/.changeset/green-vans-leave.md b/.changeset/green-vans-leave.md new file mode 100644 index 00000000000..fef495e2cdb --- /dev/null +++ b/.changeset/green-vans-leave.md @@ -0,0 +1,5 @@ +--- +'@graphql-eslint/eslint-plugin': minor +--- + +[naming-convention]: add new options `requiredPrefixes`, `requiredSuffixes` diff --git a/packages/plugin/src/rules/naming-convention.ts b/packages/plugin/src/rules/naming-convention.ts index b5292377aa8..61cef6fadab 100644 --- a/packages/plugin/src/rules/naming-convention.ts +++ b/packages/plugin/src/rules/naming-convention.ts @@ -3,7 +3,13 @@ import { FromSchema } from 'json-schema-to-ts'; import { GraphQLESTreeNode } from '../estree-converter/index.js'; import { GraphQLESLintRuleListener } from '../testkit.js'; import { GraphQLESLintRule, ValueOf } from '../types.js'; -import { ARRAY_DEFAULT_OPTIONS, convertCase, truthy, TYPES_KINDS } from '../utils.js'; +import { + ARRAY_DEFAULT_OPTIONS, + convertCase, + englishJoinWords, + truthy, + TYPES_KINDS, +} from '../utils.js'; const KindToDisplayName = { // types @@ -57,6 +63,8 @@ const schema = { suffix: { type: 'string' }, forbiddenPrefixes: ARRAY_DEFAULT_OPTIONS, forbiddenSuffixes: ARRAY_DEFAULT_OPTIONS, + requiredPrefixes: ARRAY_DEFAULT_OPTIONS, + requiredSuffixes: ARRAY_DEFAULT_OPTIONS, ignorePattern: { type: 'string', description: 'Option to skip validation of some words, e.g. acronyms', @@ -113,6 +121,8 @@ type PropertySchema = { prefix?: string; forbiddenPrefixes?: string[]; forbiddenSuffixes?: string[]; + requiredPrefixes?: string[]; + requiredSuffixes?: string[]; ignorePattern?: string; }; @@ -194,6 +204,46 @@ export const rule: GraphQLESLintRule = { } `, }, + { + title: 'Correct', + usage: [ + { + 'FieldDefinition[gqlType.name.value=Boolean]': { + style: 'camelCase', + requiredPrefixes: ['is', 'has'], + }, + 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { + style: 'camelCase', + requiredPrefixes: ['is', 'has'], + }, + }, + ], + code: /* GraphQL */ ` + type Product { + isBackordered: Boolean + isNew: Boolean! + hasDiscount: Boolean! + } + `, + }, + { + title: 'Correct', + usage: [ + { + 'FieldDefinition[gqlType.gqlType.name.value=SensitiveSecret]': { + style: 'camelCase', + requiredSuffixes: ['SensitiveSecret'], + }, + }, + ], + code: /* GraphQL */ ` + scalar SensitiveSecret + + type Account { + accountSensitiveSecret: SensitiveSecret! + } + `, + }, ], configOptions: { schema: [ @@ -250,17 +300,15 @@ export const rule: GraphQLESLintRule = { function report( node: GraphQLESTreeNode, message: string, - suggestedName: string, + suggestedNames: string[], ): void { context.report({ node, message, - suggest: [ - { - desc: `Rename to \`${suggestedName}\``, - fix: fixer => fixer.replaceText(node as any, suggestedName), - }, - ], + suggest: suggestedNames.map(suggestedName => ({ + desc: `Rename to \`${suggestedName}\``, + fix: fixer => fixer.replaceText(node as any, suggestedName), + })), }); } @@ -269,22 +317,32 @@ export const rule: GraphQLESLintRule = { if (!node) { return; } - const { prefix, suffix, forbiddenPrefixes, forbiddenSuffixes, style, ignorePattern } = - normalisePropertyOption(selector); + const { + prefix, + suffix, + forbiddenPrefixes, + forbiddenSuffixes, + style, + ignorePattern, + requiredPrefixes, + requiredSuffixes, + } = normalisePropertyOption(selector); const nodeType = KindToDisplayName[n.kind] || n.kind; const nodeName = node.value; const error = getError(); if (error) { - const { errorMessage, renameToName } = error; + const { errorMessage, renameToNames } = error; const [leadingUnderscores] = nodeName.match(/^_*/) as RegExpMatchArray; const [trailingUnderscores] = nodeName.match(/_*$/) as RegExpMatchArray; - const suggestedName = leadingUnderscores + renameToName + trailingUnderscores; - report(node, `${nodeType} "${nodeName}" should ${errorMessage}`, suggestedName); + const suggestedNames = renameToNames.map( + renameToName => leadingUnderscores + renameToName + trailingUnderscores, + ); + report(node, `${nodeType} "${nodeName}" should ${errorMessage}`, suggestedNames); } function getError(): { errorMessage: string; - renameToName: string; + renameToNames: string[]; } | void { const name = nodeName.replace(/(^_+)|(_+$)/g, ''); if (ignorePattern && new RegExp(ignorePattern, 'u').test(name)) { @@ -293,27 +351,53 @@ export const rule: GraphQLESLintRule = { if (prefix && !name.startsWith(prefix)) { return { errorMessage: `have "${prefix}" prefix`, - renameToName: prefix + name, + renameToNames: [prefix + name], }; } if (suffix && !name.endsWith(suffix)) { return { errorMessage: `have "${suffix}" suffix`, - renameToName: name + suffix, + renameToNames: [name + suffix], }; } const forbiddenPrefix = forbiddenPrefixes?.find(prefix => name.startsWith(prefix)); if (forbiddenPrefix) { return { errorMessage: `not have "${forbiddenPrefix}" prefix`, - renameToName: name.replace(new RegExp(`^${forbiddenPrefix}`), ''), + renameToNames: [name.replace(new RegExp(`^${forbiddenPrefix}`), '')], }; } const forbiddenSuffix = forbiddenSuffixes?.find(suffix => name.endsWith(suffix)); if (forbiddenSuffix) { return { errorMessage: `not have "${forbiddenSuffix}" suffix`, - renameToName: name.replace(new RegExp(`${forbiddenSuffix}$`), ''), + renameToNames: [name.replace(new RegExp(`${forbiddenSuffix}$`), '')], + }; + } + if ( + requiredPrefixes && + !requiredPrefixes.some(requiredPrefix => name.startsWith(requiredPrefix)) + ) { + return { + errorMessage: `have one of the following prefixes: ${englishJoinWords( + requiredPrefixes, + )}`, + renameToNames: style + ? requiredPrefixes.map(prefix => convertCase(style, `${prefix} ${name}`)) + : requiredPrefixes.map(prefix => `${prefix}${name}`), + }; + } + if ( + requiredSuffixes && + !requiredSuffixes.some(requiredSuffix => name.endsWith(requiredSuffix)) + ) { + return { + errorMessage: `have one of the following suffixes: ${englishJoinWords( + requiredSuffixes, + )}`, + renameToNames: style + ? requiredSuffixes.map(suffix => convertCase(style, `${name} ${suffix}`)) + : requiredSuffixes.map(suffix => `${name}${suffix}`), }; } // Style is optional @@ -324,7 +408,7 @@ export const rule: GraphQLESLintRule = { if (!caseRegex.test(name)) { return { errorMessage: `be in ${style} format`, - renameToName: convertCase(style, name), + renameToNames: [convertCase(style, name)], }; } } @@ -332,11 +416,9 @@ export const rule: GraphQLESLintRule = { const checkUnderscore = (isLeading: boolean) => (node: GraphQLESTreeNode) => { const suggestedName = node.value.replace(isLeading ? /^_+/ : /_+$/, ''); - report( - node, - `${isLeading ? 'Leading' : 'Trailing'} underscores are not allowed`, + report(node, `${isLeading ? 'Leading' : 'Trailing'} underscores are not allowed`, [ suggestedName, - ); + ]); }; const listeners: GraphQLESLintRuleListener = {}; diff --git a/packages/plugin/tests/__snapshots__/naming-convention.spec.md b/packages/plugin/tests/__snapshots__/naming-convention.spec.md index 044e25a84ef..43a27f75d6f 100644 --- a/packages/plugin/tests/__snapshots__/naming-convention.spec.md +++ b/packages/plugin/tests/__snapshots__/naming-convention.spec.md @@ -2170,6 +2170,191 @@ exports[`schema-recommended config 1`] = ` 15 | } `; +exports[`should error when selected type names do not match require prefixes 1`] = ` +#### ⌨️ Code + + 1 | scalar Secret + 2 | + 3 | interface Snake { + 4 | value: String! + 5 | } + 6 | + 7 | type Test { + 8 | enabled: Boolean! + 9 | secret: Secret! + 10 | snake: Snake + 11 | } + +#### ⚙️ Options + + { + "FieldDefinition[gqlType.gqlType.name.value=Boolean]": { + "style": "camelCase", + "requiredPrefixes": [ + "is", + "has" + ] + }, + "FieldDefinition[gqlType.gqlType.name.value=Secret]": { + "requiredPrefixes": [ + "SUPER_SECRET_" + ] + }, + "FieldDefinition[gqlType.name.value=Snake]": { + "style": "snake_case", + "requiredPrefixes": [ + "hiss" + ] + } + } + +#### ❌ Error 1/3 + + 7 | type Test { + > 8 | enabled: Boolean! + | ^^^^^^^ Field "enabled" should have one of the following prefixes: is or has + 9 | secret: Secret! + +#### 💡 Suggestion 1/2: Rename to \`isEnabled\` + + 1 | scalar Secret + 2 | + 3 | interface Snake { + 4 | value: String! + 5 | } + 6 | + 7 | type Test { + 8 | isEnabled: Boolean! + 9 | secret: Secret! + 10 | snake: Snake + 11 | } + +#### 💡 Suggestion 2/2: Rename to \`hasEnabled\` + + 1 | scalar Secret + 2 | + 3 | interface Snake { + 4 | value: String! + 5 | } + 6 | + 7 | type Test { + 8 | hasEnabled: Boolean! + 9 | secret: Secret! + 10 | snake: Snake + 11 | } + +#### ❌ Error 2/3 + + 8 | enabled: Boolean! + > 9 | secret: Secret! + | ^^^^^^ Field "secret" should have one of the following prefixes: SUPER_SECRET_ + 10 | snake: Snake + +#### 💡 Suggestion: Rename to \`SUPER_SECRET_secret\` + + 1 | scalar Secret + 2 | + 3 | interface Snake { + 4 | value: String! + 5 | } + 6 | + 7 | type Test { + 8 | enabled: Boolean! + 9 | SUPER_SECRET_secret: Secret! + 10 | snake: Snake + 11 | } + +#### ❌ Error 3/3 + + 9 | secret: Secret! + > 10 | snake: Snake + | ^^^^^ Field "snake" should have one of the following prefixes: hiss + 11 | } + +#### 💡 Suggestion: Rename to \`hiss_snake\` + + 1 | scalar Secret + 2 | + 3 | interface Snake { + 4 | value: String! + 5 | } + 6 | + 7 | type Test { + 8 | enabled: Boolean! + 9 | secret: Secret! + 10 | hiss_snake: Snake + 11 | } +`; + +exports[`should error when selected type names do not match require suffixes 1`] = ` +#### ⌨️ Code + + 1 | scalar IpAddress + 2 | + 3 | type Test { + 4 | specialFeature: Boolean! + 5 | user: IpAddress! + 6 | } + +#### ⚙️ Options + + { + "FieldDefinition[gqlType.gqlType.name.value=Boolean]": { + "style": "camelCase", + "requiredSuffixes": [ + "Enabled", + "Disabled" + ] + }, + "FieldDefinition[gqlType.gqlType.name.value=IpAddress]": { + "requiredSuffixes": [ + "IpAddress" + ] + } + } + +#### ❌ Error 1/2 + + 3 | type Test { + > 4 | specialFeature: Boolean! + | ^^^^^^^^^^^^^^ Field "specialFeature" should have one of the following suffixes: Enabled or Disabled + 5 | user: IpAddress! + +#### 💡 Suggestion 1/2: Rename to \`specialFeatureEnabled\` + + 1 | scalar IpAddress + 2 | + 3 | type Test { + 4 | specialFeatureEnabled: Boolean! + 5 | user: IpAddress! + 6 | } + +#### 💡 Suggestion 2/2: Rename to \`specialFeatureDisabled\` + + 1 | scalar IpAddress + 2 | + 3 | type Test { + 4 | specialFeatureDisabled: Boolean! + 5 | user: IpAddress! + 6 | } + +#### ❌ Error 2/2 + + 4 | specialFeature: Boolean! + > 5 | user: IpAddress! + | ^^^^ Field "user" should have one of the following suffixes: IpAddress + 6 | } + +#### 💡 Suggestion: Rename to \`userIpAddress\` + + 1 | scalar IpAddress + 2 | + 3 | type Test { + 4 | specialFeature: Boolean! + 5 | userIpAddress: IpAddress! + 6 | } +`; + exports[`should ignore selections fields but check alias renaming 1`] = ` #### ⌨️ Code diff --git a/packages/plugin/tests/naming-convention.spec.ts b/packages/plugin/tests/naming-convention.spec.ts index c9089d01a90..8502edc712b 100644 --- a/packages/plugin/tests/naming-convention.spec.ts +++ b/packages/plugin/tests/naming-convention.spec.ts @@ -160,6 +160,57 @@ ruleTester.runGraphQLTests('naming-convention', rule, { code: 'type T', options: [{ ObjectTypeDefinition: 'UPPER_CASE' }], }, + { + code: /* GraphQL */ ` + scalar Secret + + interface Snake { + value: String! + } + + type Test { + isEnabled: Boolean! + SUPER_SECRET_secret: Secret! + hiss_snake: Snake + } + `, + options: [ + { + 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { + style: 'camelCase', + requiredPrefixes: ['is', 'has'], + }, + 'FieldDefinition[gqlType.gqlType.name.value=Secret]': { + requiredPrefixes: ['SUPER_SECRET_'], + }, + 'FieldDefinition[gqlType.name.value=Snake]': { + style: 'snake_case', + requiredPrefixes: ['hiss_'], + }, + }, + ], + }, + { + code: /* GraphQL */ ` + scalar IpAddress + + type Test { + specialFeatureEnabled: Boolean! + userIpAddress: IpAddress! + } + `, + options: [ + { + 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { + style: 'camelCase', + requiredSuffixes: ['Enabled', 'Disabled'], + }, + 'FieldDefinition[gqlType.gqlType.name.value=IpAddress]': { + requiredSuffixes: ['IpAddress'], + }, + }, + ], + }, ], invalid: [ { @@ -404,5 +455,60 @@ ruleTester.runGraphQLTests('naming-convention', rule, { { message: 'Trailing underscores are not allowed' }, ], }, + { + name: 'should error when selected type names do not match require prefixes', + code: /* GraphQL */ ` + scalar Secret + + interface Snake { + value: String! + } + + type Test { + enabled: Boolean! + secret: Secret! + snake: Snake + } + `, + options: [ + { + 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { + style: 'camelCase', + requiredPrefixes: ['is', 'has'], + }, + 'FieldDefinition[gqlType.gqlType.name.value=Secret]': { + requiredPrefixes: ['SUPER_SECRET_'], + }, + 'FieldDefinition[gqlType.name.value=Snake]': { + style: 'snake_case', + requiredPrefixes: ['hiss'], + }, + }, + ], + errors: 3, + }, + { + name: 'should error when selected type names do not match require suffixes', + code: /* GraphQL */ ` + scalar IpAddress + + type Test { + specialFeature: Boolean! + user: IpAddress! + } + `, + options: [ + { + 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { + style: 'camelCase', + requiredSuffixes: ['Enabled', 'Disabled'], + }, + 'FieldDefinition[gqlType.gqlType.name.value=IpAddress]': { + requiredSuffixes: ['IpAddress'], + }, + }, + ], + errors: 2, + }, ], });