diff --git a/.changeset/weak-readers-build.md b/.changeset/weak-readers-build.md new file mode 100644 index 00000000000..616df45564c --- /dev/null +++ b/.changeset/weak-readers-build.md @@ -0,0 +1,5 @@ +--- +'@graphql-eslint/eslint-plugin': minor +--- + +Support the fragment spread group when defining alphabetize rule's groups with new option `...` diff --git a/.github/workflows/pr.yml.backup b/.github/workflows/pr.yml similarity index 95% rename from .github/workflows/pr.yml.backup rename to .github/workflows/pr.yml index f5fe8f18b3d..1d41fda8a8c 100644 --- a/.github/workflows/pr.yml.backup +++ b/.github/workflows/pr.yml @@ -14,7 +14,7 @@ jobs: with: npmTag: alpha buildScript: prerelease - nodeVersion: 18 + nodeVersion: 22 packageManager: pnpm secrets: githubToken: ${{ secrets.GUILD_BOT_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 09a11f56806..fa293760c21 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ jobs: uses: the-guild-org/shared-config/.github/workflows/release-stable.yml@main with: releaseScript: release - nodeVersion: 18 + nodeVersion: 22 packageManager: pnpm secrets: githubToken: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 6008a782ecd..277626b8540 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,7 +41,7 @@ jobs: - name: Setup ENV uses: the-guild-org/shared-config/setup@main with: - nodeVersion: 18 + nodeVersion: 22 packageManager: pnpm - name: Build diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 3c414087819..1c11045343d 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -21,7 +21,7 @@ jobs: - uses: the-guild-org/shared-config/setup@main name: setup env with: - nodeVersion: 18 + nodeVersion: 22 packageManager: pnpm - uses: the-guild-org/shared-config/website-cf@main diff --git a/packages/plugin/src/configs/operations-all.ts b/packages/plugin/src/configs/operations-all.ts index 20c4c10afe1..d132ea0daec 100644 --- a/packages/plugin/src/configs/operations-all.ts +++ b/packages/plugin/src/configs/operations-all.ts @@ -13,7 +13,7 @@ export = { selections: ['OperationDefinition', 'FragmentDefinition'], variables: true, arguments: ['Field', 'Directive'], - groups: ['id', '*', 'createdAt', 'updatedAt'], + groups: ['...', 'id', '*', 'createdAt', 'updatedAt'], }, ], '@graphql-eslint/lone-executable-definition': 'error', diff --git a/packages/plugin/src/rules/alphabetize/index.test.ts b/packages/plugin/src/rules/alphabetize/index.test.ts index 446559062ab..b98e385aa3e 100644 --- a/packages/plugin/src/rules/alphabetize/index.test.ts +++ b/packages/plugin/src/rules/alphabetize/index.test.ts @@ -444,5 +444,65 @@ ruleTester.run('alphabetize', rule, { `, errors: 3, }, + { + name: 'should sort selections by group when `...` is at the start', + options: [ + { + selections: ['OperationDefinition'], + groups: ['...', 'id', '*', 'createdAt', 'updatedAt'], + }, + ], + code: /* GraphQL */ ` + { + zz + updatedAt + createdAt + aa + id + ...ChildFragment + } + `, + errors: 4, + }, + { + name: 'should sort selections by group when `...` is between', + options: [ + { + selections: ['FragmentDefinition'], + groups: ['id', '*', '...', 'createdAt', 'updatedAt'], + }, + ], + code: /* GraphQL */ ` + fragment foo on Foo { + zz + ...ChildFragment + updatedAt + createdAt + aa + id + } + `, + errors: 3, + }, + { + name: 'should sort selections by group when `...` is at the end', + options: [ + { + selections: ['OperationDefinition'], + groups: ['id', '*', 'createdAt', 'updatedAt', '...'], + }, + ], + code: /* GraphQL */ ` + { + ...ChildFragment + zz + updatedAt + createdAt + aa + id + } + `, + errors: 4, + }, ], }); diff --git a/packages/plugin/src/rules/alphabetize/index.ts b/packages/plugin/src/rules/alphabetize/index.ts index da73fbcd578..d7533417dab 100644 --- a/packages/plugin/src/rules/alphabetize/index.ts +++ b/packages/plugin/src/rules/alphabetize/index.ts @@ -94,7 +94,7 @@ const schema = { ...ARRAY_DEFAULT_OPTIONS, minItems: 2, description: - "Custom order group. Example: `['id', '*', 'createdAt', 'updatedAt']` where `*` says for everything else.", + "Custom order group. Example: `['id', '*', 'createdAt', 'updatedAt', '...']` where `...` stands for fragment spreads, and `*` stands for everything else.", }, }, }, @@ -203,7 +203,7 @@ export const rule: GraphQLESLintRule = { selections: selectionsEnum, variables: true, arguments: [Kind.FIELD, Kind.DIRECTIVE], - groups: ['id', '*', 'createdAt', 'updatedAt'], + groups: ['...', 'id', '*', 'createdAt', 'updatedAt'], }, ], }, @@ -260,18 +260,14 @@ export const rule: GraphQLESLintRule = { // Starts from 1, ignore nodes.length <= 1 for (let i = 1; i < nodes.length; i += 1) { const currNode = nodes[i]; - const currName = - ('alias' in currNode && currNode.alias?.value) || - ('name' in currNode && currNode.name?.value); + const currName = getName(currNode); if (!currName) { // we don't move unnamed current nodes continue; } const prevNode = nodes[i - 1]; - const prevName = - ('alias' in prevNode && prevNode.alias?.value) || - ('name' in prevNode && prevNode.name?.value); + const prevName = getName(prevNode); if (prevName) { // Compare with lexicographic order const compareResult = prevName.localeCompare(currName); @@ -283,10 +279,9 @@ export const rule: GraphQLESLintRule = { if (!groups.includes('*')) { throw new Error('`groups` option should contain `*` string.'); } - let indexForPrev = groups.indexOf(prevName); - if (indexForPrev === -1) indexForPrev = groups.indexOf('*'); - let indexForCurr = groups.indexOf(currName); - if (indexForCurr === -1) indexForCurr = groups.indexOf('*'); + const indexForPrev = getIndex({ node: prevNode, groups }); + const indexForCurr = getIndex({ node: currNode, groups }); + shouldSortByGroup = indexForPrev - indexForCurr > 0; if (indexForPrev < indexForCurr) { continue; @@ -412,3 +407,34 @@ export const rule: GraphQLESLintRule = { return listeners; }, }; + +function getIndex({ + node, + groups, +}: { + node: GraphQLESTreeNode; + groups: string[]; +}): number { + // Try an exact match + let index = groups.indexOf(getName(node)); + + // Check for the fragment spread group + if (index === -1 && node.kind === Kind.FRAGMENT_SPREAD) { + index = groups.indexOf('...'); + } + + // Check for the catch-all group + if (index === -1) { + index = groups.indexOf('*'); + } + return index; +} + +function getName(node: GraphQLESTreeNode): string { + return ( + ('alias' in node && node.alias?.value) || + // + ('name' in node && node.name?.value) || + '' + ); +} diff --git a/packages/plugin/src/rules/alphabetize/snapshot.md b/packages/plugin/src/rules/alphabetize/snapshot.md index 287c08f5962..7f57fa7332c 100644 --- a/packages/plugin/src/rules/alphabetize/snapshot.md +++ b/packages/plugin/src/rules/alphabetize/snapshot.md @@ -1088,6 +1088,200 @@ exports[`alphabetize > invalid > should sort selections by group when \`*\` is b 7 | } `; +exports[`alphabetize > invalid > should sort selections by group when \`...\` is at the end 1`] = ` +#### ⌨️ Code + + 1 | { + 2 | ...ChildFragment + 3 | zz + 4 | updatedAt + 5 | createdAt + 6 | aa + 7 | id + 8 | } + +#### ⚙️ Options + + { + "selections": [ + "OperationDefinition" + ], + "groups": [ + "id", + "*", + "createdAt", + "updatedAt", + "..." + ] + } + +#### ❌ Error 1/4 + + 2 | ...ChildFragment + > 3 | zz + | ^^ field "zz" should be before fragment spread "ChildFragment" + 4 | updatedAt + +#### ❌ Error 2/4 + + 4 | updatedAt + > 5 | createdAt + | ^^^^^^^^^ field "createdAt" should be before field "updatedAt" + 6 | aa + +#### ❌ Error 3/4 + + 5 | createdAt + > 6 | aa + | ^^ field "aa" should be before field "createdAt" + 7 | id + +#### ❌ Error 4/4 + + 6 | aa + > 7 | id + | ^^ field "id" should be before field "aa" + 8 | } + +#### 🔧 Autofix output + + 1 | { + 2 | id + 3 | aa + 4 | zz + 5 | createdAt + 6 | updatedAt + 7 | ...ChildFragment + 8 | } +`; + +exports[`alphabetize > invalid > should sort selections by group when \`...\` is at the start 1`] = ` +#### ⌨️ Code + + 1 | { + 2 | zz + 3 | updatedAt + 4 | createdAt + 5 | aa + 6 | id + 7 | ...ChildFragment + 8 | } + +#### ⚙️ Options + + { + "selections": [ + "OperationDefinition" + ], + "groups": [ + "...", + "id", + "*", + "createdAt", + "updatedAt" + ] + } + +#### ❌ Error 1/4 + + 3 | updatedAt + > 4 | createdAt + | ^^^^^^^^^ field "createdAt" should be before field "updatedAt" + 5 | aa + +#### ❌ Error 2/4 + + 4 | createdAt + > 5 | aa + | ^^ field "aa" should be before field "createdAt" + 6 | id + +#### ❌ Error 3/4 + + 5 | aa + > 6 | id + | ^^ field "id" should be before field "aa" + 7 | ...ChildFragment + +#### ❌ Error 4/4 + + 6 | id + > 7 | ...ChildFragment + | ^^^^^^^^^^^^^ fragment spread "ChildFragment" should be before field "id" + 8 | } + +#### 🔧 Autofix output + + 1 | { + 2 | ...ChildFragment + 3 | id + 4 | aa + 5 | zz + 6 | createdAt + 7 | updatedAt + 8 | } +`; + +exports[`alphabetize > invalid > should sort selections by group when \`...\` is between 1`] = ` +#### ⌨️ Code + + 1 | fragment foo on Foo { + 2 | zz + 3 | ...ChildFragment + 4 | updatedAt + 5 | createdAt + 6 | aa + 7 | id + 8 | } + +#### ⚙️ Options + + { + "selections": [ + "FragmentDefinition" + ], + "groups": [ + "id", + "*", + "...", + "createdAt", + "updatedAt" + ] + } + +#### ❌ Error 1/3 + + 4 | updatedAt + > 5 | createdAt + | ^^^^^^^^^ field "createdAt" should be before field "updatedAt" + 6 | aa + +#### ❌ Error 2/3 + + 5 | createdAt + > 6 | aa + | ^^ field "aa" should be before field "createdAt" + 7 | id + +#### ❌ Error 3/3 + + 6 | aa + > 7 | id + | ^^ field "id" should be before field "aa" + 8 | } + +#### 🔧 Autofix output + + 1 | fragment foo on Foo { + 2 | id + 3 | aa + 4 | zz + 5 | ...ChildFragment + 6 | createdAt + 7 | updatedAt + 8 | } +`; + exports[`alphabetize > invalid > should sort when selection is aliased 1`] = ` #### ⌨️ Code diff --git a/packages/plugin/src/utils.ts b/packages/plugin/src/utils.ts index bbf63523b4d..34a78cbce0b 100644 --- a/packages/plugin/src/utils.ts +++ b/packages/plugin/src/utils.ts @@ -128,27 +128,50 @@ export function truthy(value: T): value is Truthy { return !!value; } -const DisplayNodeNameMap: Record = { - [Kind.OBJECT_TYPE_DEFINITION]: 'type', - [Kind.OBJECT_TYPE_EXTENSION]: 'type', - [Kind.INTERFACE_TYPE_DEFINITION]: 'interface', - [Kind.INTERFACE_TYPE_EXTENSION]: 'interface', +const DisplayNodeNameMap: Record = { + [Kind.ARGUMENT]: 'argument', + [Kind.BOOLEAN]: 'boolean', + [Kind.DIRECTIVE_DEFINITION]: 'directive', + [Kind.DIRECTIVE]: 'directive', + [Kind.DOCUMENT]: 'document', [Kind.ENUM_TYPE_DEFINITION]: 'enum', [Kind.ENUM_TYPE_EXTENSION]: 'enum', - [Kind.SCALAR_TYPE_DEFINITION]: 'scalar', + [Kind.ENUM_VALUE_DEFINITION]: 'enum value', + [Kind.ENUM]: 'enum', + [Kind.FIELD_DEFINITION]: 'field', + [Kind.FIELD]: 'field', + [Kind.FLOAT]: 'float', + [Kind.FRAGMENT_DEFINITION]: 'fragment', + [Kind.FRAGMENT_SPREAD]: 'fragment spread', + [Kind.INLINE_FRAGMENT]: 'inline fragment', [Kind.INPUT_OBJECT_TYPE_DEFINITION]: 'input', [Kind.INPUT_OBJECT_TYPE_EXTENSION]: 'input', + [Kind.INPUT_VALUE_DEFINITION]: 'input value', + [Kind.INT]: 'int', + [Kind.INTERFACE_TYPE_DEFINITION]: 'interface', + [Kind.INTERFACE_TYPE_EXTENSION]: 'interface', + [Kind.LIST_TYPE]: 'list type', + [Kind.LIST]: 'list', + [Kind.NAME]: 'name', + [Kind.NAMED_TYPE]: 'named type', + [Kind.NON_NULL_TYPE]: 'non-null type', + [Kind.NULL]: 'null', + [Kind.OBJECT_FIELD]: 'object field', + [Kind.OBJECT_TYPE_DEFINITION]: 'type', + [Kind.OBJECT_TYPE_EXTENSION]: 'type', + [Kind.OBJECT]: 'object', + [Kind.OPERATION_DEFINITION]: 'operation', + [Kind.OPERATION_TYPE_DEFINITION]: 'operation type', + [Kind.SCALAR_TYPE_DEFINITION]: 'scalar', + [Kind.SCALAR_TYPE_EXTENSION]: 'scalar', + [Kind.SCHEMA_DEFINITION]: 'schema', + [Kind.SCHEMA_EXTENSION]: 'schema', + [Kind.SELECTION_SET]: 'selection set', + [Kind.STRING]: 'string', [Kind.UNION_TYPE_DEFINITION]: 'union', [Kind.UNION_TYPE_EXTENSION]: 'union', - [Kind.DIRECTIVE_DEFINITION]: 'directive', - [Kind.FIELD_DEFINITION]: 'field', - [Kind.ENUM_VALUE_DEFINITION]: 'enum value', - [Kind.INPUT_VALUE_DEFINITION]: 'input value', - [Kind.ARGUMENT]: 'argument', + [Kind.VARIABLE_DEFINITION]: 'variable', [Kind.VARIABLE]: 'variable', - [Kind.FRAGMENT_DEFINITION]: 'fragment', - [Kind.OPERATION_DEFINITION]: 'operation', - [Kind.FIELD]: 'field', } as const; export function displayNodeName(node: GraphQLESTreeNode): string { diff --git a/website/src/pages/rules/alphabetize.md b/website/src/pages/rules/alphabetize.md index 6cb0ff89dda..a4dacb5b2ca 100644 --- a/website/src/pages/rules/alphabetize.md +++ b/website/src/pages/rules/alphabetize.md @@ -165,8 +165,8 @@ Definitions – `type`, `interface`, `enum`, `scalar`, `input`, `union` and `dir ### `groups` (array) -Custom order group. Example: `['id', '*', 'createdAt', 'updatedAt']` where `*` says for everything -else. +Custom order group. Example: `['id', '*', 'createdAt', 'updatedAt', '...']` where `...` stands for +fragment spreads, and `*` stands for everything else. The object is an array with all elements of the type `string`.