From 10b9975147e031717f955f2be747924bb800f237 Mon Sep 17 00:00:00 2001 From: Dimitri POSTOLOV Date: Tue, 10 Dec 2024 01:55:25 +0700 Subject: [PATCH] allow to config `naming-convention` for Relay fragment convention `_` via `requiredPattern` option (#2838) * aa * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Merge branch 'master' into use-search-params * Update .changeset/polite-impalas-float.md * Apply suggestions from code review --- .changeset/polite-impalas-float.md | 8 + .../rules/match-document-filename/index.ts | 12 +- .../src/rules/naming-convention/index.test.ts | 113 +++++++++++-- .../src/rules/naming-convention/index.ts | 158 ++++++++++++------ .../src/rules/naming-convention/snapshot.md | 150 ++++++++--------- .../src/rules/require-description/index.ts | 48 +++--- packages/rule-tester/src/index.ts | 6 +- scripts/generate-docs.ts | 3 + website/app/layout.tsx | 2 +- website/app/play/page.client.tsx | 2 +- .../content/rules/match-document-filename.mdx | 2 + website/content/rules/naming-convention.mdx | 44 +++-- 12 files changed, 351 insertions(+), 197 deletions(-) create mode 100644 .changeset/polite-impalas-float.md diff --git a/.changeset/polite-impalas-float.md b/.changeset/polite-impalas-float.md new file mode 100644 index 00000000000..2d1f25f70ba --- /dev/null +++ b/.changeset/polite-impalas-float.md @@ -0,0 +1,8 @@ +--- +'@graphql-eslint/eslint-plugin': minor +--- + +- allow to config `naming-convention` for Relay fragment convention `_` + via `requiredPattern` option + +- replace `requiredPatterns: RegEx[]` by `requiredPattern: RegEx` option diff --git a/packages/plugin/src/rules/match-document-filename/index.ts b/packages/plugin/src/rules/match-document-filename/index.ts index 282de79f2aa..6aa595b0bf9 100644 --- a/packages/plugin/src/rules/match-document-filename/index.ts +++ b/packages/plugin/src/rules/match-document-filename/index.ts @@ -28,18 +28,20 @@ const schemaOption = { oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asObject' }], } as const; +const caseSchema = { + enum: CASE_STYLES, + description: `One of: ${CASE_STYLES.map(t => `\`${t}\``).join(', ')}`, +}; + const schema = { definitions: { - asString: { - enum: CASE_STYLES, - description: `One of: ${CASE_STYLES.map(t => `\`${t}\``).join(', ')}`, - }, + asString: caseSchema, asObject: { type: 'object', additionalProperties: false, minProperties: 1, properties: { - style: { enum: CASE_STYLES }, + style: caseSchema, suffix: { type: 'string' }, prefix: { type: 'string' }, }, diff --git a/packages/plugin/src/rules/naming-convention/index.test.ts b/packages/plugin/src/rules/naming-convention/index.test.ts index ba7f5e77cb3..455fe627227 100644 --- a/packages/plugin/src/rules/naming-convention/index.test.ts +++ b/packages/plugin/src/rules/naming-convention/index.test.ts @@ -237,6 +237,48 @@ ruleTester.run('naming-convention', rule, { }, ], }, + { + name: 'requiredPattern with case style in prefix', + options: [ + { + FragmentDefinition: { + style: 'PascalCase', + requiredPattern: /^(?.+?)_/, + }, + }, + ], + code: /* GraphQL */ ` + fragment myUser_UserProfileFields on User { + id + } + `, + parserOptions: { + graphQLConfig: { + schema: 'type User', + }, + }, + }, + { + name: 'requiredPattern with case style in suffix', + options: [ + { + FragmentDefinition: { + style: 'PascalCase', + requiredPattern: /_(?.+?)$/, + }, + }, + ], + code: /* GraphQL */ ` + fragment UserProfileFields_my_user on User { + id + } + `, + parserOptions: { + graphQLConfig: { + schema: 'type User', + }, + }, + }, ], invalid: [ { @@ -446,15 +488,42 @@ ruleTester.run('naming-convention', rule, { `, options: (rule.meta.docs!.configOptions as any).operations, errors: [ - { message: 'Query "TestQuery" should not have "Query" suffix' }, - { message: 'Query "QueryTest" should not have "Query" prefix' }, - { message: 'Query "GetQuery" should not have "Get" prefix' }, - { message: 'Mutation "TestMutation" should not have "Mutation" suffix' }, - { message: 'Mutation "MutationTest" should not have "Mutation" prefix' }, - { message: 'Subscription "TestSubscription" should not have "Subscription" suffix' }, - { message: 'Subscription "SubscriptionTest" should not have "Subscription" prefix' }, - { message: 'Fragment "TestFragment" should not have "Fragment" suffix' }, - { message: 'Fragment "FragmentTest" should not have "Fragment" prefix' }, + { + message: + 'Query "TestQuery" should not contain the forbidden pattern "/(query|mutation|subscription)$/i"', + }, + { + message: + 'Query "QueryTest" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i"', + }, + { + message: + 'Query "GetQuery" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i"', + }, + { + message: + 'Mutation "TestMutation" should not contain the forbidden pattern "/(query|mutation|subscription)$/i"', + }, + { + message: + 'Mutation "MutationTest" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i"', + }, + { + message: + 'Subscription "TestSubscription" should not contain the forbidden pattern "/(query|mutation|subscription)$/i"', + }, + { + message: + 'Subscription "SubscriptionTest" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i"', + }, + { + message: + 'Fragment "TestFragment" should not contain the forbidden pattern "/(^fragment)|(fragment$)/i"', + }, + { + message: + 'Fragment "FragmentTest" should not contain the forbidden pattern "/(^fragment)|(fragment$)/i"', + }, ], }, { @@ -536,17 +605,39 @@ ruleTester.run('naming-convention', rule, { errors: 2, }, { - name: 'requiredPatterns', + name: 'requiredPattern', code: 'type Test { enabled: Boolean! }', options: [ { 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { style: 'camelCase', - requiredPatterns: [/^(is|has)/], + requiredPattern: /^(is|has)/, }, }, ], errors: 1, }, + { + name: 'requiredPattern with case style in suffix', + options: [ + { + FragmentDefinition: { + style: 'PascalCase', + requiredPattern: /_(?.+?)$/, + }, + }, + ], + code: /* GraphQL */ ` + fragment UserProfileFields on User { + id + } + `, + parserOptions: { + graphQLConfig: { + schema: 'type User', + }, + }, + errors: 1, + }, ], }); diff --git a/packages/plugin/src/rules/naming-convention/index.ts b/packages/plugin/src/rules/naming-convention/index.ts index 739128800b9..5f7d2c446d1 100644 --- a/packages/plugin/src/rules/naming-convention/index.ts +++ b/packages/plugin/src/rules/naming-convention/index.ts @@ -4,6 +4,7 @@ import { GraphQLESTreeNode } from '../../estree-converter/index.js'; import { GraphQLESLintRule, GraphQLESLintRuleListener, ValueOf } from '../../types.js'; import { ARRAY_DEFAULT_OPTIONS, + CaseStyle, convertCase, displayNodeName, englishJoinWords, @@ -47,22 +48,24 @@ const schemaOption = { oneOf: [{ $ref: '#/definitions/asString' }, { $ref: '#/definitions/asObject' }], } as const; -const descriptionPrefixesSuffixes = (name: 'forbiddenPatterns' | 'requiredPatterns') => +const descriptionPrefixesSuffixes = (name: 'forbiddenPatterns' | 'requiredPattern', id: string) => `> [!WARNING] > -> This option is deprecated and will be removed in the next major release. Use [\`${name}\`](#${name.toLowerCase()}-array) instead.`; +> This option is deprecated and will be removed in the next major release. Use [\`${name}\`](#${id}) instead.`; + +const caseSchema = { + enum: ALLOWED_STYLES, + description: `One of: ${ALLOWED_STYLES.map(t => `\`${t}\``).join(', ')}`, +}; const schema = { definitions: { - asString: { - enum: ALLOWED_STYLES, - description: `One of: ${ALLOWED_STYLES.map(t => `\`${t}\``).join(', ')}`, - }, + asString: caseSchema, asObject: { type: 'object', additionalProperties: false, properties: { - style: { enum: ALLOWED_STYLES }, + style: caseSchema, prefix: { type: 'string' }, suffix: { type: 'string' }, forbiddenPatterns: { @@ -72,28 +75,25 @@ const schema = { }, description: 'Should be of instance of `RegEx`', }, - requiredPatterns: { - ...ARRAY_DEFAULT_OPTIONS, - items: { - type: 'object', - }, + requiredPattern: { + type: 'object', description: 'Should be of instance of `RegEx`', }, forbiddenPrefixes: { ...ARRAY_DEFAULT_OPTIONS, - description: descriptionPrefixesSuffixes('forbiddenPatterns'), + description: descriptionPrefixesSuffixes('forbiddenPatterns', 'forbiddenpatterns-array'), }, forbiddenSuffixes: { ...ARRAY_DEFAULT_OPTIONS, - description: descriptionPrefixesSuffixes('forbiddenPatterns'), + description: descriptionPrefixesSuffixes('forbiddenPatterns', 'forbiddenpatterns-array'), }, requiredPrefixes: { ...ARRAY_DEFAULT_OPTIONS, - description: descriptionPrefixesSuffixes('requiredPatterns'), + description: descriptionPrefixesSuffixes('requiredPattern', 'requiredpattern-object'), }, requiredSuffixes: { ...ARRAY_DEFAULT_OPTIONS, - description: descriptionPrefixesSuffixes('requiredPatterns'), + description: descriptionPrefixesSuffixes('requiredPattern', 'requiredpattern-object'), }, ignorePattern: { type: 'string', @@ -152,7 +152,7 @@ type PropertySchema = { suffix?: string; prefix?: string; forbiddenPatterns?: RegExp[]; - requiredPatterns?: RegExp[]; + requiredPattern?: RegExp; forbiddenPrefixes?: string[]; forbiddenSuffixes?: string[]; requiredPrefixes?: string[]; @@ -184,7 +184,14 @@ export const rule: GraphQLESLintRule = { }, { title: 'Incorrect', - usage: [{ FragmentDefinition: { style: 'PascalCase', forbiddenSuffixes: ['Fragment'] } }], + usage: [ + { + FragmentDefinition: { + style: 'PascalCase', + forbiddenPatterns: [/(^fragment)|(fragment$)/i], + }, + }, + ], code: /* GraphQL */ ` fragment UserFragment on User { # ... @@ -193,7 +200,7 @@ export const rule: GraphQLESLintRule = { }, { title: 'Incorrect', - usage: [{ 'FieldDefinition[parent.name.value=Query]': { forbiddenPrefixes: ['get'] } }], + usage: [{ 'FieldDefinition[parent.name.value=Query]': { forbiddenPatterns: [/^get/i] } }], code: /* GraphQL */ ` type Query { getUsers: [User!]! @@ -211,7 +218,14 @@ export const rule: GraphQLESLintRule = { }, { title: 'Correct', - usage: [{ FragmentDefinition: { style: 'PascalCase', forbiddenSuffixes: ['Fragment'] } }], + usage: [ + { + FragmentDefinition: { + style: 'PascalCase', + forbiddenPatterns: [/(^fragment)|(fragment$)/i], + }, + }, + ], code: /* GraphQL */ ` fragment UserFields on User { # ... @@ -220,7 +234,7 @@ export const rule: GraphQLESLintRule = { }, { title: 'Correct', - usage: [{ 'FieldDefinition[parent.name.value=Query]': { forbiddenPrefixes: ['get'] } }], + usage: [{ 'FieldDefinition[parent.name.value=Query]': { forbiddenPatterns: [/^get/i] } }], code: /* GraphQL */ ` type Query { users: [User!]! @@ -244,11 +258,11 @@ export const rule: GraphQLESLintRule = { { 'FieldDefinition[gqlType.name.value=Boolean]': { style: 'camelCase', - requiredPrefixes: ['is', 'has'], + requiredPattern: /^(is|has)/, }, 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { style: 'camelCase', - requiredPrefixes: ['is', 'has'], + requiredPattern: /^(is|has)/, }, }, ], @@ -266,7 +280,7 @@ export const rule: GraphQLESLintRule = { { 'FieldDefinition[gqlType.gqlType.name.value=SensitiveSecret]': { style: 'camelCase', - requiredSuffixes: ['SensitiveSecret'], + requiredPattern: /SensitiveSecret$/, }, }, ], @@ -278,6 +292,27 @@ export const rule: GraphQLESLintRule = { } `, }, + { + title: 'Correct (Relay fragment convention `_`)', + usage: [ + { + FragmentDefinition: { + style: 'PascalCase', + requiredPattern: /_(?.+?)$/, + }, + }, + ], + code: /* GraphQL */ ` + # schema + type User { + # ... + } + # operations + fragment UserFields_data on User { + # ... + } + `, + }, ], configOptions: { schema: [ @@ -289,32 +324,25 @@ export const rule: GraphQLESLintRule = { DirectiveDefinition: 'camelCase', EnumValueDefinition: 'UPPER_CASE', 'FieldDefinition[parent.name.value=Query]': { - forbiddenPrefixes: ['query', 'get'], - forbiddenSuffixes: ['Query'], + forbiddenPatterns: [/^(query|get)/i, /query$/i], }, 'FieldDefinition[parent.name.value=Mutation]': { - forbiddenPrefixes: ['mutation'], - forbiddenSuffixes: ['Mutation'], + forbiddenPatterns: [/(^mutation)|(mutation$)/i], }, 'FieldDefinition[parent.name.value=Subscription]': { - forbiddenPrefixes: ['subscription'], - forbiddenSuffixes: ['Subscription'], + forbiddenPatterns: [/(^subscription)|(subscription$)/i], }, 'EnumTypeDefinition,EnumTypeExtension': { - forbiddenPrefixes: ['Enum'], - forbiddenSuffixes: ['Enum'], + forbiddenPatterns: [/(^enum)|(enum$)/i], }, 'InterfaceTypeDefinition,InterfaceTypeExtension': { - forbiddenPrefixes: ['Interface'], - forbiddenSuffixes: ['Interface'], + forbiddenPatterns: [/(^interface)|(interface$)/i], }, 'UnionTypeDefinition,UnionTypeExtension': { - forbiddenPrefixes: ['Union'], - forbiddenSuffixes: ['Union'], + forbiddenPatterns: [/(^union)|(union$)/i], }, 'ObjectTypeDefinition,ObjectTypeExtension': { - forbiddenPrefixes: ['Type'], - forbiddenSuffixes: ['Type'], + forbiddenPatterns: [/(^type)|(type$)/i], }, }, ], @@ -323,13 +351,14 @@ export const rule: GraphQLESLintRule = { VariableDefinition: 'camelCase', OperationDefinition: { style: 'PascalCase', - forbiddenPrefixes: ['Query', 'Mutation', 'Subscription', 'Get'], - forbiddenSuffixes: ['Query', 'Mutation', 'Subscription'], + forbiddenPatterns: [ + /^(query|mutation|subscription|get)/i, + /(query|mutation|subscription)$/i, + ], }, FragmentDefinition: { style: 'PascalCase', - forbiddenPrefixes: ['Fragment'], - forbiddenSuffixes: ['Fragment'], + forbiddenPatterns: [/(^fragment)|(fragment$)/i], }, }, ], @@ -378,7 +407,7 @@ export const rule: GraphQLESLintRule = { requiredPrefixes, requiredSuffixes, forbiddenPatterns, - requiredPatterns, + requiredPattern, } = normalisePropertyOption(selector); const nodeName = node.value; const error = getError(); @@ -401,7 +430,9 @@ export const rule: GraphQLESLintRule = { errorMessage: string; renameToNames: string[]; } | void { - const name = nodeName.replace(/(^_+)|(_+$)/g, ''); + let name = nodeName; + if (allowLeadingUnderscore) name = name.replace(/^_+/, ''); + if (allowTrailingUnderscore) name = name.replace(/_+$/, ''); if (ignorePattern && new RegExp(ignorePattern, 'u').test(name)) { if ('name' in n) { ignoredNodes.add(n.name); @@ -420,6 +451,39 @@ export const rule: GraphQLESLintRule = { renameToNames: [name + suffix], }; } + if (requiredPattern) { + if (requiredPattern.source.includes('(?<')) { + try { + name = name.replace(requiredPattern, (originalString, ...args) => { + const groups = args.at(-1); + // eslint-disable-next-line no-unreachable-loop -- expected + for (const [styleName, value] of Object.entries(groups)) { + if (!(styleName in StyleToRegex)) { + throw new Error('Invalid case style in `requiredPatterns` option'); + } + if (value === convertCase(styleName as CaseStyle, value as string)) { + return ''; + } + throw new Error(`contain the required pattern: ${requiredPattern}`); + } + return originalString; + }); + if (name === nodeName) { + throw new Error(`contain the required pattern: ${requiredPattern}`); + } + } catch (error) { + return { + errorMessage: (error as Error).message, + renameToNames: [], + }; + } + } else if (!requiredPattern.test(name)) { + return { + errorMessage: `contain the required pattern: ${requiredPattern}`, + renameToNames: [], + }; + } + } const forbidden = forbiddenPatterns?.find(pattern => pattern.test(name)); if (forbidden) { return { @@ -427,12 +491,6 @@ export const rule: GraphQLESLintRule = { renameToNames: [name.replace(forbidden, '')], }; } - if (requiredPatterns && !requiredPatterns.some(pattern => pattern.test(name))) { - return { - errorMessage: `contain the required pattern: ${englishJoinWords(requiredPatterns.map(re => re.source))}`, - renameToNames: [], - }; - } const forbiddenPrefix = forbiddenPrefixes?.find(prefix => name.startsWith(prefix)); if (forbiddenPrefix) { return { diff --git a/packages/plugin/src/rules/naming-convention/snapshot.md b/packages/plugin/src/rules/naming-convention/snapshot.md index 12750ad44fc..e1a96ab5ed3 100644 --- a/packages/plugin/src/rules/naming-convention/snapshot.md +++ b/packages/plugin/src/rules/naming-convention/snapshot.md @@ -1744,25 +1744,15 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` "VariableDefinition": "camelCase", "OperationDefinition": { "style": "PascalCase", - "forbiddenPrefixes": [ - "Query", - "Mutation", - "Subscription", - "Get" - ], - "forbiddenSuffixes": [ - "Query", - "Mutation", - "Subscription" + "forbiddenPatterns": [ + "/^(query|mutation|subscription|get)/i", + "/(query|mutation|subscription)$/i" ] }, "FragmentDefinition": { "style": "PascalCase", - "forbiddenPrefixes": [ - "Fragment" - ], - "forbiddenSuffixes": [ - "Fragment" + "forbiddenPatterns": [ + "/(^fragment)|(fragment$)/i" ] } } @@ -1770,7 +1760,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` #### ❌ Error 1/9 > 1 | query TestQuery { test } - | ^^^^^^^^^ Query "TestQuery" should not have "Query" suffix + | ^^^^^^^^^ Query "TestQuery" should not contain the forbidden pattern "/(query|mutation|subscription)$/i" 2 | query QueryTest { test } #### 💡 Suggestion: Rename to \`Test\` @@ -1793,7 +1783,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 1 | query TestQuery { test } > 2 | query QueryTest { test } - | ^^^^^^^^^ Query "QueryTest" should not have "Query" prefix + | ^^^^^^^^^ Query "QueryTest" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i" 3 | query GetQuery { test } #### 💡 Suggestion: Rename to \`Test\` @@ -1816,7 +1806,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 2 | query QueryTest { test } > 3 | query GetQuery { test } - | ^^^^^^^^ Query "GetQuery" should not have "Get" prefix + | ^^^^^^^^ Query "GetQuery" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i" 4 | query Test { test(CONTROLLED_BY_SCHEMA: 0) } #### 💡 Suggestion: Rename to \`Query\` @@ -1839,7 +1829,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 5 | > 6 | mutation TestMutation { test } - | ^^^^^^^^^^^^ Mutation "TestMutation" should not have "Mutation" suffix + | ^^^^^^^^^^^^ Mutation "TestMutation" should not contain the forbidden pattern "/(query|mutation|subscription)$/i" 7 | mutation MutationTest { test } #### 💡 Suggestion: Rename to \`Test\` @@ -1862,7 +1852,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 6 | mutation TestMutation { test } > 7 | mutation MutationTest { test } - | ^^^^^^^^^^^^ Mutation "MutationTest" should not have "Mutation" prefix + | ^^^^^^^^^^^^ Mutation "MutationTest" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i" 8 | #### 💡 Suggestion: Rename to \`Test\` @@ -1885,7 +1875,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 8 | > 9 | subscription TestSubscription { test } - | ^^^^^^^^^^^^^^^^ Subscription "TestSubscription" should not have "Subscription" suffix + | ^^^^^^^^^^^^^^^^ Subscription "TestSubscription" should not contain the forbidden pattern "/(query|mutation|subscription)$/i" 10 | subscription SubscriptionTest { test } #### 💡 Suggestion: Rename to \`Test\` @@ -1908,7 +1898,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 9 | subscription TestSubscription { test } > 10 | subscription SubscriptionTest { test } - | ^^^^^^^^^^^^^^^^ Subscription "SubscriptionTest" should not have "Subscription" prefix + | ^^^^^^^^^^^^^^^^ Subscription "SubscriptionTest" should not contain the forbidden pattern "/^(query|mutation|subscription|get)/i" 11 | #### 💡 Suggestion: Rename to \`Test\` @@ -1931,7 +1921,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 11 | > 12 | fragment TestFragment on Test { id } - | ^^^^^^^^^^^^ Fragment "TestFragment" should not have "Fragment" suffix + | ^^^^^^^^^^^^ Fragment "TestFragment" should not contain the forbidden pattern "/(^fragment)|(fragment$)/i" 13 | fragment FragmentTest on Test { id } #### 💡 Suggestion: Rename to \`Test\` @@ -1954,7 +1944,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 12 | fragment TestFragment on Test { id } > 13 | fragment FragmentTest on Test { id } - | ^^^^^^^^^^^^ Fragment "FragmentTest" should not have "Fragment" prefix + | ^^^^^^^^^^^^ Fragment "FragmentTest" should not contain the forbidden pattern "/(^fragment)|(fragment$)/i" #### 💡 Suggestion: Rename to \`Test\` @@ -1973,7 +1963,7 @@ exports[`naming-convention > invalid > operations-recommended config 1`] = ` 13 | fragment Test on Test { id } `; -exports[`naming-convention > invalid > requiredPatterns 1`] = ` +exports[`naming-convention > invalid > requiredPattern 1`] = ` #### ⌨️ Code 1 | type Test { enabled: Boolean! } @@ -1983,16 +1973,37 @@ exports[`naming-convention > invalid > requiredPatterns 1`] = ` { "FieldDefinition[gqlType.gqlType.name.value=Boolean]": { "style": "camelCase", - "requiredPatterns": [ - "/^(is|has)/" - ] + "requiredPattern": "/^(is|has)/" } } #### ❌ Error > 1 | type Test { enabled: Boolean! } - | ^^^^^^^ Field "enabled" should contain the required pattern: ^(is|has) + | ^^^^^^^ Field "enabled" should contain the required pattern: /^(is|has)/ +`; + +exports[`naming-convention > invalid > requiredPattern with case style in suffix 1`] = ` +#### ⌨️ Code + + 1 | fragment UserProfileFields on User { + 2 | id + 3 | } + +#### ⚙️ Options + + { + "FragmentDefinition": { + "style": "PascalCase", + "requiredPattern": "/_(?.+?)$/" + } + } + +#### ❌ Error + + > 1 | fragment UserProfileFields on User { + | ^^^^^^^^^^^^^^^^^ Fragment "UserProfileFields" should contain the required pattern: /_(?.+?)$/ + 2 | id `; exports[`naming-convention > invalid > schema-recommended config 1`] = ` @@ -2042,60 +2053,39 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` "DirectiveDefinition": "camelCase", "EnumValueDefinition": "UPPER_CASE", "FieldDefinition[parent.name.value=Query]": { - "forbiddenPrefixes": [ - "query", - "get" - ], - "forbiddenSuffixes": [ - "Query" + "forbiddenPatterns": [ + "/^(query|get)/i", + "/query$/i" ] }, "FieldDefinition[parent.name.value=Mutation]": { - "forbiddenPrefixes": [ - "mutation" - ], - "forbiddenSuffixes": [ - "Mutation" + "forbiddenPatterns": [ + "/(^mutation)|(mutation$)/i" ] }, "FieldDefinition[parent.name.value=Subscription]": { - "forbiddenPrefixes": [ - "subscription" - ], - "forbiddenSuffixes": [ - "Subscription" + "forbiddenPatterns": [ + "/(^subscription)|(subscription$)/i" ] }, "EnumTypeDefinition,EnumTypeExtension": { - "forbiddenPrefixes": [ - "Enum" - ], - "forbiddenSuffixes": [ - "Enum" + "forbiddenPatterns": [ + "/(^enum)|(enum$)/i" ] }, "InterfaceTypeDefinition,InterfaceTypeExtension": { - "forbiddenPrefixes": [ - "Interface" - ], - "forbiddenSuffixes": [ - "Interface" + "forbiddenPatterns": [ + "/(^interface)|(interface$)/i" ] }, "UnionTypeDefinition,UnionTypeExtension": { - "forbiddenPrefixes": [ - "Union" - ], - "forbiddenSuffixes": [ - "Union" + "forbiddenPatterns": [ + "/(^union)|(union$)/i" ] }, "ObjectTypeDefinition,ObjectTypeExtension": { - "forbiddenPrefixes": [ - "Type" - ], - "forbiddenSuffixes": [ - "Type" + "forbiddenPatterns": [ + "/(^type)|(type$)/i" ] } } @@ -2104,7 +2094,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 1 | type Query { > 2 | fieldQuery: ID - | ^^^^^^^^^^ Field "fieldQuery" should not have "Query" suffix + | ^^^^^^^^^^ Field "fieldQuery" should not contain the forbidden pattern "/query$/i" 3 | queryField: ID #### 💡 Suggestion: Rename to \`field\` @@ -2147,7 +2137,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 2 | fieldQuery: ID > 3 | queryField: ID - | ^^^^^^^^^^ Field "queryField" should not have "query" prefix + | ^^^^^^^^^^ Field "queryField" should not contain the forbidden pattern "/^(query|get)/i" 4 | getField: ID #### 💡 Suggestion: Rename to \`Field\` @@ -2190,7 +2180,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 3 | queryField: ID > 4 | getField: ID - | ^^^^^^^^ Field "getField" should not have "get" prefix + | ^^^^^^^^ Field "getField" should not contain the forbidden pattern "/^(query|get)/i" 5 | } #### 💡 Suggestion: Rename to \`Field\` @@ -2233,7 +2223,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 7 | type Mutation { > 8 | fieldMutation: ID - | ^^^^^^^^^^^^^ Field "fieldMutation" should not have "Mutation" suffix + | ^^^^^^^^^^^^^ Field "fieldMutation" should not contain the forbidden pattern "/(^mutation)|(mutation$)/i" 9 | mutationField: ID #### 💡 Suggestion: Rename to \`field\` @@ -2276,7 +2266,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 8 | fieldMutation: ID > 9 | mutationField: ID - | ^^^^^^^^^^^^^ Field "mutationField" should not have "mutation" prefix + | ^^^^^^^^^^^^^ Field "mutationField" should not contain the forbidden pattern "/(^mutation)|(mutation$)/i" 10 | } #### 💡 Suggestion: Rename to \`Field\` @@ -2319,7 +2309,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 12 | type Subscription { > 13 | fieldSubscription: ID - | ^^^^^^^^^^^^^^^^^ Field "fieldSubscription" should not have "Subscription" suffix + | ^^^^^^^^^^^^^^^^^ Field "fieldSubscription" should not contain the forbidden pattern "/(^subscription)|(subscription$)/i" 14 | subscriptionField: ID #### 💡 Suggestion: Rename to \`field\` @@ -2362,7 +2352,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 13 | fieldSubscription: ID > 14 | subscriptionField: ID - | ^^^^^^^^^^^^^^^^^ Field "subscriptionField" should not have "subscription" prefix + | ^^^^^^^^^^^^^^^^^ Field "subscriptionField" should not contain the forbidden pattern "/(^subscription)|(subscription$)/i" 15 | } #### 💡 Suggestion: Rename to \`Field\` @@ -2405,7 +2395,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 16 | > 17 | enum TestEnum - | ^^^^^^^^ Enum "TestEnum" should not have "Enum" suffix + | ^^^^^^^^ Enum "TestEnum" should not contain the forbidden pattern "/(^enum)|(enum$)/i" 18 | extend enum EnumTest { #### 💡 Suggestion: Rename to \`Test\` @@ -2448,7 +2438,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 17 | enum TestEnum > 18 | extend enum EnumTest { - | ^^^^^^^^ Enum "EnumTest" should not have "Enum" prefix + | ^^^^^^^^ Enum "EnumTest" should not contain the forbidden pattern "/(^enum)|(enum$)/i" 19 | A #### 💡 Suggestion: Rename to \`Test\` @@ -2491,7 +2481,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 21 | > 22 | interface TestInterface - | ^^^^^^^^^^^^^ Interface "TestInterface" should not have "Interface" suffix + | ^^^^^^^^^^^^^ Interface "TestInterface" should not contain the forbidden pattern "/(^interface)|(interface$)/i" 23 | extend interface InterfaceTest { #### 💡 Suggestion: Rename to \`Test\` @@ -2534,7 +2524,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 22 | interface TestInterface > 23 | extend interface InterfaceTest { - | ^^^^^^^^^^^^^ Interface "InterfaceTest" should not have "Interface" prefix + | ^^^^^^^^^^^^^ Interface "InterfaceTest" should not contain the forbidden pattern "/(^interface)|(interface$)/i" 24 | id: ID #### 💡 Suggestion: Rename to \`Test\` @@ -2577,7 +2567,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 26 | > 27 | union TestUnion - | ^^^^^^^^^ Union "TestUnion" should not have "Union" suffix + | ^^^^^^^^^ Union "TestUnion" should not contain the forbidden pattern "/(^union)|(union$)/i" 28 | extend union UnionTest = TestInterface #### 💡 Suggestion: Rename to \`Test\` @@ -2620,7 +2610,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 27 | union TestUnion > 28 | extend union UnionTest = TestInterface - | ^^^^^^^^^ Union "UnionTest" should not have "Union" prefix + | ^^^^^^^^^ Union "UnionTest" should not contain the forbidden pattern "/(^union)|(union$)/i" 29 | #### 💡 Suggestion: Rename to \`Test\` @@ -2663,7 +2653,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 29 | > 30 | type TestType - | ^^^^^^^^ Type "TestType" should not have "Type" suffix + | ^^^^^^^^ Type "TestType" should not contain the forbidden pattern "/(^type)|(type$)/i" 31 | extend type TypeTest { #### 💡 Suggestion: Rename to \`Test\` @@ -2706,7 +2696,7 @@ exports[`naming-convention > invalid > schema-recommended config 1`] = ` 30 | type TestType > 31 | extend type TypeTest { - | ^^^^^^^^ Type "TypeTest" should not have "Type" prefix + | ^^^^^^^^ Type "TypeTest" should not contain the forbidden pattern "/(^type)|(type$)/i" 32 | id: ID #### 💡 Suggestion: Rename to \`Test\` diff --git a/packages/plugin/src/rules/require-description/index.ts b/packages/plugin/src/rules/require-description/index.ts index bdcb4a8d2d6..12bd6cbd28b 100644 --- a/packages/plugin/src/rules/require-description/index.ts +++ b/packages/plugin/src/rules/require-description/index.ts @@ -1,4 +1,5 @@ import { ASTKindToNode, Kind, TokenKind } from 'graphql'; +import { FromSchema } from 'json-schema-to-ts'; import { getRootTypeNames } from '@graphql-tools/utils'; import { GraphQLESTreeNode } from '../../estree-converter/index.js'; import { GraphQLESLintRule, ValueOf } from '../../types.js'; @@ -26,6 +27,24 @@ type AllowedKind = (typeof ALLOWED_KINDS)[number]; type AllowedKindToNode = Pick; type SelectorNode = GraphQLESTreeNode>; +const entries: Record = Object.create(null); + +for (const kind of [...ALLOWED_KINDS].sort()) { + let description = `> [!NOTE] +> +> Read more about this kind on [spec.graphql.org](https://spec.graphql.org/October2021/#${kind}).`; + if (kind === Kind.OPERATION_DEFINITION) { + description += [ + '', + '', + '> [!WARNING]', + '>', + '> You must use only comment syntax `#` and not description syntax `"""` or `"`.', + ].join('\n'); + } + entries[kind] = { type: 'boolean', description }; +} + const schema = { type: 'array', minItems: 1, @@ -49,37 +68,12 @@ const schema = { ...ARRAY_DEFAULT_OPTIONS, description: ['Ignore specific selectors', eslintSelectorsTip].join('\n'), }, - ...Object.fromEntries( - [...ALLOWED_KINDS].sort().map(kind => { - let description = `> [!NOTE] -> -> Read more about this kind on [spec.graphql.org](https://spec.graphql.org/October2021/#${kind}).`; - if (kind === Kind.OPERATION_DEFINITION) { - description += [ - '', - '', - '> [!WARNING]', - '>', - '> You must use only comment syntax `#` and not description syntax `"""` or `"`.', - ].join('\n'); - } - return [kind, { type: 'boolean', description }]; - }), - ), + ...entries, }, }, } as const; -// TODO try import { FromSchema } from 'json-schema-to-ts'; -export type RuleOptions = [ - { - [key in AllowedKind]?: boolean; - } & { - types?: true; - rootField?: true; - ignoredSelectors?: string[]; - }, -]; +export type RuleOptions = FromSchema; export const rule: GraphQLESLintRule = { meta: { diff --git a/packages/rule-tester/src/index.ts b/packages/rule-tester/src/index.ts index 07e9c72b29b..4e8893d27ef 100644 --- a/packages/rule-tester/src/index.ts +++ b/packages/rule-tester/src/index.ts @@ -26,13 +26,11 @@ function applyFix(code: string, { range, text }: Rule.Fix): string { } // @ts-expect-error -- Extend RegExp with a custom toJSON method -RegExp.prototype.toJSON = function () { - return `/${this.source}/${this.flags}`; -}; +RegExp.prototype.toJSON = RegExp.prototype.toString; export class RuleTester extends ESLintRuleTester { fromMockFile(path: string): string { - return readFileSync(resolve(__dirname, `../../plugin/__tests__/mocks/${path}`), 'utf-8'); + return readFileSync(resolve(__dirname, `../../plugin/__tests__/mocks/${path}`), 'utf8'); } // @ts-expect-error -- fix later diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 9db7ed728d3..b1f478df7fa 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -28,6 +28,9 @@ type Column = { align: 'center' | 'right'; }; +// @ts-expect-error -- Extend RegExp with a custom toJSON method to print RegEx in examples +RegExp.prototype.toJSON = RegExp.prototype.toString; + function printMarkdownTable(columns: (Column | string)[], dataSource: string[][]): string { const headerRow: string[] = []; const alignRow: ('-:' | '-' | ':-:')[] = []; diff --git a/website/app/layout.tsx b/website/app/layout.tsx index 55b509ad5c4..87ad8a5d7ec 100644 --- a/website/app/layout.tsx +++ b/website/app/layout.tsx @@ -59,7 +59,7 @@ const RootLayout: FC<{ icon: , children: 'GitHub', }, - ] + ], }} > {children} diff --git a/website/app/play/page.client.tsx b/website/app/play/page.client.tsx index 5a8a814bbc5..69428f51c90 100644 --- a/website/app/play/page.client.tsx +++ b/website/app/play/page.client.tsx @@ -48,7 +48,7 @@ function useSetterSearchParams( const handleChange = useCallback((value: string) => { router.push(pathname + createQueryString(searchParams, paramKey, value)); - }, []); + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- only on mount return [searchParams.get(paramKey) ?? defaultValue, handleChange]; } diff --git a/website/content/rules/match-document-filename.mdx b/website/content/rules/match-document-filename.mdx index b5d0df00537..576c8401e76 100644 --- a/website/content/rules/match-document-filename.mdx +++ b/website/content/rules/match-document-filename.mdx @@ -167,6 +167,8 @@ Properties of the `asObject` object: ### `style` (enum) +One of: `camelCase`, `PascalCase`, `snake_case`, `UPPER_CASE`, `kebab-case`, `matchDocumentStyle` + This element must be one of the following enum values: - `camelCase` diff --git a/website/content/rules/naming-convention.mdx b/website/content/rules/naming-convention.mdx index 9011f499497..e144adfea60 100644 --- a/website/content/rules/naming-convention.mdx +++ b/website/content/rules/naming-convention.mdx @@ -35,7 +35,7 @@ type user { ### Incorrect ```graphql -# eslint @graphql-eslint/naming-convention: ['error', { FragmentDefinition: { style: 'PascalCase', forbiddenSuffixes: ['Fragment'] } }] +# eslint @graphql-eslint/naming-convention: ['error', { FragmentDefinition: { style: 'PascalCase', forbiddenPatterns: ['/fragment$/i'] } }] fragment UserFragment on User { # ... @@ -45,7 +45,7 @@ fragment UserFragment on User { ### Incorrect ```graphql -# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[parent.name.value=Query]': { forbiddenPrefixes: ['get'] } }] +# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[parent.name.value=Query]': { forbiddenPatterns: ['/^get/i'] } }] type Query { getUsers: [User!]! @@ -65,7 +65,7 @@ type User { ### Correct ```graphql -# eslint @graphql-eslint/naming-convention: ['error', { FragmentDefinition: { style: 'PascalCase', forbiddenSuffixes: ['Fragment'] } }] +# eslint @graphql-eslint/naming-convention: ['error', { FragmentDefinition: { style: 'PascalCase', forbiddenPatterns: ['/fragment$/i'] } }] fragment UserFields on User { # ... @@ -75,7 +75,7 @@ fragment UserFields on User { ### Correct ```graphql -# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[parent.name.value=Query]': { forbiddenPrefixes: ['get'] } }] +# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[parent.name.value=Query]': { forbiddenPatterns: ['/^get/i'] } }] type Query { users: [User!]! @@ -97,7 +97,7 @@ type Product { ### Correct ```graphql -# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[gqlType.name.value=Boolean]': { style: 'camelCase', requiredPrefixes: ['is', 'has'] }, 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { style: 'camelCase', requiredPrefixes: ['is', 'has'] } }] +# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[gqlType.name.value=Boolean]': { style: 'camelCase', requiredPattern: '/^(is|has)/' }, 'FieldDefinition[gqlType.gqlType.name.value=Boolean]': { style: 'camelCase', requiredPattern: '/^(is|has)/' } }] type Product { isBackordered: Boolean @@ -109,7 +109,7 @@ type Product { ### Correct ```graphql -# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[gqlType.gqlType.name.value=SensitiveSecret]': { style: 'camelCase', requiredSuffixes: ['SensitiveSecret'] } }] +# eslint @graphql-eslint/naming-convention: ['error', { 'FieldDefinition[gqlType.gqlType.name.value=SensitiveSecret]': { style: 'camelCase', requiredPattern: '/SensitiveSecret$/' } }] scalar SensitiveSecret @@ -118,6 +118,21 @@ type Account { } ``` +### Correct (Relay fragment convention `_`) + +```graphql +# eslint @graphql-eslint/naming-convention: ['error', { FragmentDefinition: { style: 'PascalCase', requiredPattern: '/_(?.+?)$/' } }] + +# schema +type User { + # ... +} +# operations +fragment UserFields_data on User { + # ... +} +``` + ## Config Schema > It's possible to use a [`selector`](https://eslint.org/docs/developer-guide/selectors) that starts @@ -338,6 +353,8 @@ Properties of the `asObject` object: ### `style` (enum) +One of: `camelCase`, `PascalCase`, `snake_case`, `UPPER_CASE` + This element must be one of the following enum values: - `camelCase` @@ -362,19 +379,10 @@ Additional restrictions: - Minimum items: `1` - Unique items: `true` -### `requiredPatterns` (array) +### `requiredPattern` (object) Should be of instance of `RegEx` -The object is an array with all elements of the type `object`. - -The array object has the following properties: - -Additional restrictions: - -- Minimum items: `1` -- Unique items: `true` - ### `forbiddenPrefixes` (array) > [!WARNING] @@ -408,7 +416,7 @@ Additional restrictions: > [!WARNING] > > This option is deprecated and will be removed in the next major release. Use -> [`requiredPatterns`](#requiredpatterns-array) instead. +> [`requiredPattern`](#requiredpattern-object) instead. The object is an array with all elements of the type `string`. @@ -422,7 +430,7 @@ Additional restrictions: > [!WARNING] > > This option is deprecated and will be removed in the next major release. Use -> [`requiredPatterns`](#requiredpatterns-array) instead. +> [`requiredPattern`](#requiredpattern-object) instead. The object is an array with all elements of the type `string`.