diff --git a/.changeset/spotty-meals-think.md b/.changeset/spotty-meals-think.md new file mode 100644 index 00000000000..bbe3017a530 --- /dev/null +++ b/.changeset/spotty-meals-think.md @@ -0,0 +1,6 @@ +--- +'@graphql-eslint/eslint-plugin': minor +--- + +Add new `require-import-fragment` rule that reports fragments which were not imported via an import +expression. diff --git a/packages/plugin/src/rules/index.ts b/packages/plugin/src/rules/index.ts index b1c227beea6..73726192ca4 100644 --- a/packages/plugin/src/rules/index.ts +++ b/packages/plugin/src/rules/index.ts @@ -29,6 +29,7 @@ import { rule as requireDeprecationReason } from './require-deprecation-reason.j import { rule as requireDescription } from './require-description.js'; import { rule as requireFieldOfTypeQueryInMutationResult } from './require-field-of-type-query-in-mutation-result.js'; import { rule as requireIdWhenAvailable } from './require-id-when-available.js'; +import { rule as requireImportFragment } from './require-import-fragment.js'; import { rule as requireNullableFieldsWithOneof } from './require-nullable-fields-with-oneof.js'; import { rule as requireTypePatternWithOneof } from './require-type-pattern-with-oneof.js'; import { rule as selectionSetDepth } from './selection-set-depth.js'; @@ -64,6 +65,7 @@ export const rules = { 'require-description': requireDescription, 'require-field-of-type-query-in-mutation-result': requireFieldOfTypeQueryInMutationResult, 'require-id-when-available': requireIdWhenAvailable, + 'require-import-fragment': requireImportFragment, 'require-nullable-fields-with-oneof': requireNullableFieldsWithOneof, 'require-type-pattern-with-oneof': requireTypePatternWithOneof, 'selection-set-depth': selectionSetDepth, diff --git a/packages/plugin/src/rules/lone-executable-definition.ts b/packages/plugin/src/rules/lone-executable-definition.ts index fccd0b9f716..38fefef92a1 100644 --- a/packages/plugin/src/rules/lone-executable-definition.ts +++ b/packages/plugin/src/rules/lone-executable-definition.ts @@ -78,7 +78,7 @@ export const rule: GraphQLESLintRule = { definitions.push({ type, node }); } }, - 'Program:exit'() { + 'Document:exit'() { for (const { node, type } of definitions.slice(1) /* ignore first definition */) { let name = pascalCase(type); const definitionName = node.name?.value; diff --git a/packages/plugin/src/rules/require-import-fragment.ts b/packages/plugin/src/rules/require-import-fragment.ts new file mode 100644 index 00000000000..94df25fd633 --- /dev/null +++ b/packages/plugin/src/rules/require-import-fragment.ts @@ -0,0 +1,128 @@ +import path from 'path'; +import { NameNode } from 'graphql'; +import { requireSiblingsOperations } from '../utils.js'; +import { GraphQLESTreeNode } from '../estree-converter/index.js'; +import { GraphQLESLintRule } from '../types.js'; + +const RULE_ID = 'require-import-fragment'; +const SUGGESTION_ID = 'add-import-expression'; + +export const rule: GraphQLESLintRule = { + meta: { + type: 'suggestion', + docs: { + category: 'Operations', + description: 'Require fragments to be imported via an import expression.', + url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${RULE_ID}.md`, + examples: [ + { + title: 'Incorrect', + code: /* GraphQL */ ` + query { + user { + ...UserFields + } + } + `, + }, + { + title: 'Incorrect', + code: /* GraphQL */ ` + # import 'post-fields.fragment.graphql' + query { + user { + ...UserFields + } + } + `, + }, + { + title: 'Incorrect', + code: /* GraphQL */ ` + # import UserFields from 'post-fields.fragment.graphql' + query { + user { + ...UserFields + } + } + `, + }, + { + title: 'Correct', + code: /* GraphQL */ ` + # import UserFields from 'user-fields.fragment.graphql' + query { + user { + ...UserFields + } + } + `, + }, + ], + requiresSiblings: true, + isDisabledForAllConfig: true, + }, + hasSuggestions: true, + messages: { + [RULE_ID]: 'Expected "{{fragmentName}}" fragment to be imported.', + [SUGGESTION_ID]: 'Add import expression for "{{fragmentName}}".', + }, + schema: [], + }, + create(context) { + const comments = context.getSourceCode().getAllComments(); + const siblings = requireSiblingsOperations(RULE_ID, context); + const filePath = context.getFilename(); + + return { + 'FragmentSpread > .name'(node: GraphQLESTreeNode) { + const fragmentName = node.value; + const fragmentsFromSiblings = siblings.getFragment(fragmentName); + + for (const comment of comments) { + if (comment.type !== 'Line') continue; + + // 1. could start with extra whitespace + // 2. match both named/default import + const isPossibleImported = new RegExp( + `^\\s*import\\s+(${fragmentName}\\s+from\\s+)?['"]`, + ).test(comment.value); + if (!isPossibleImported) continue; + + const extractedImportPath = comment.value.match(/(["'])((?:\1|.)*?)\1/)?.[2]; + if (!extractedImportPath) continue; + + const importPath = path.join(path.dirname(filePath), extractedImportPath); + const hasInSiblings = fragmentsFromSiblings.some( + source => source.filePath === importPath, + ); + if (hasInSiblings) return; + } + + const fragmentInSameFile = fragmentsFromSiblings.some( + source => source.filePath === filePath, + ); + if (fragmentInSameFile) return; + + const suggestedFilePaths = fragmentsFromSiblings.length + ? fragmentsFromSiblings.map(o => path.relative(path.dirname(filePath), o.filePath)) + : ['CHANGE_ME.graphql']; + + context.report({ + node, + messageId: RULE_ID, + data: { fragmentName }, + suggest: suggestedFilePaths.map(suggestedPath => ({ + messageId: SUGGESTION_ID, + data: { fragmentName }, + fix: fixer => + fixer.insertTextBeforeRange( + [0, 0], + `# import ${fragmentName} from '${suggestedPath}'\n`, + ), + })), + }); + }, + }; + }, +}; diff --git a/packages/plugin/src/testkit.ts b/packages/plugin/src/testkit.ts index 22f0add9311..84cf444da59 100644 --- a/packages/plugin/src/testkit.ts +++ b/packages/plugin/src/testkit.ts @@ -13,7 +13,7 @@ export type GraphQLESLintRuleListener = Re [K in keyof ASTKindToNode]?: (node: GraphQLESTreeNode) => void; }; -export type GraphQLValidTestCase = Omit< +export type GraphQLValidTestCase = Omit< RuleTester.ValidTestCase, 'options' | 'parserOptions' > & { @@ -21,7 +21,7 @@ export type GraphQLValidTestCase = Omit< parserOptions?: Omit; }; -export type GraphQLInvalidTestCase = GraphQLValidTestCase & { +export type GraphQLInvalidTestCase = GraphQLValidTestCase & { errors: (RuleTester.TestCaseError | string)[] | number; output?: string | null; }; diff --git a/packages/plugin/tests/__snapshots__/require-import-fragment.spec.md b/packages/plugin/tests/__snapshots__/require-import-fragment.spec.md new file mode 100644 index 00000000000..8f667b2372d --- /dev/null +++ b/packages/plugin/tests/__snapshots__/require-import-fragment.spec.md @@ -0,0 +1,83 @@ +// Vitest Snapshot v1 + +exports[`should report fragments when there are no import expressions 1`] = ` +#### ⌨ī¸ Code + + 1 | { + 2 | foo { + 3 | ...FooFields + 4 | } + 5 | } + +#### ❌ Error + + 2 | foo { + > 3 | ...FooFields + | ^^^^^^^^^ Expected "FooFields" fragment to be imported. + 4 | } + +#### 💡 Suggestion: Add import expression for "FooFields". + + 1 | # import FooFields from 'foo-fragment.gql' + 2 | { + 3 | foo { + 4 | ...FooFields + 5 | } + 6 | } +`; + +exports[`should report with default import 1`] = ` +#### ⌨ī¸ Code + + 1 | #import 'bar-fragment.gql' + 2 | query { + 3 | foo { + 4 | ...FooFields + 5 | } + 6 | } + +#### ❌ Error + + 3 | foo { + > 4 | ...FooFields + | ^^^^^^^^^ Expected "FooFields" fragment to be imported. + 5 | } + +#### 💡 Suggestion: Add import expression for "FooFields". + + 1 | # import FooFields from 'foo-fragment.gql' + 2 | #import 'bar-fragment.gql' + 3 | query { + 4 | foo { + 5 | ...FooFields + 6 | } + 7 | } +`; + +exports[`should report with named import 1`] = ` +#### ⌨ī¸ Code + + 1 | #import FooFields from "bar-fragment.gql" + 2 | query { + 3 | foo { + 4 | ...FooFields + 5 | } + 6 | } + +#### ❌ Error + + 3 | foo { + > 4 | ...FooFields + | ^^^^^^^^^ Expected "FooFields" fragment to be imported. + 5 | } + +#### 💡 Suggestion: Add import expression for "FooFields". + + 1 | # import FooFields from 'foo-fragment.gql' + 2 | #import FooFields from "bar-fragment.gql" + 3 | query { + 4 | foo { + 5 | ...FooFields + 6 | } + 7 | } +`; diff --git a/packages/plugin/tests/mocks/import-fragments/bar-fragment.gql b/packages/plugin/tests/mocks/import-fragments/bar-fragment.gql new file mode 100644 index 00000000000..076759d99cc --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/bar-fragment.gql @@ -0,0 +1,3 @@ +fragment BarFields on Bar { + id +} diff --git a/packages/plugin/tests/mocks/import-fragments/foo-fragment.gql b/packages/plugin/tests/mocks/import-fragments/foo-fragment.gql new file mode 100644 index 00000000000..e4f4f123b85 --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/foo-fragment.gql @@ -0,0 +1,3 @@ +fragment FooFields on Foo { + id +} diff --git a/packages/plugin/tests/mocks/import-fragments/invalid-query-default.gql b/packages/plugin/tests/mocks/import-fragments/invalid-query-default.gql new file mode 100644 index 00000000000..a3f5a5f7302 --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/invalid-query-default.gql @@ -0,0 +1,6 @@ +#import 'bar-fragment.gql' +query { + foo { + ...FooFields + } +} diff --git a/packages/plugin/tests/mocks/import-fragments/invalid-query.gql b/packages/plugin/tests/mocks/import-fragments/invalid-query.gql new file mode 100644 index 00000000000..6fdcc59f030 --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/invalid-query.gql @@ -0,0 +1,6 @@ +#import FooFields from "bar-fragment.gql" +query { + foo { + ...FooFields + } +} diff --git a/packages/plugin/tests/mocks/import-fragments/missing-import.gql b/packages/plugin/tests/mocks/import-fragments/missing-import.gql new file mode 100644 index 00000000000..9563590e406 --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/missing-import.gql @@ -0,0 +1,5 @@ +{ + foo { + ...FooFields + } +} diff --git a/packages/plugin/tests/mocks/import-fragments/same-file.gql b/packages/plugin/tests/mocks/import-fragments/same-file.gql new file mode 100644 index 00000000000..77c4b74b223 --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/same-file.gql @@ -0,0 +1,9 @@ +{ + foo { + ...FooFields + } +} + +fragment FooFields on Foo { + id +} diff --git a/packages/plugin/tests/mocks/import-fragments/valid-query-default.gql b/packages/plugin/tests/mocks/import-fragments/valid-query-default.gql new file mode 100644 index 00000000000..0470e1b771b --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/valid-query-default.gql @@ -0,0 +1,9 @@ +# Imports could have extra whitespace and double/single quotes + +# import 'foo-fragment.gql' + +query { + foo { + ...FooFields + } +} diff --git a/packages/plugin/tests/mocks/import-fragments/valid-query.gql b/packages/plugin/tests/mocks/import-fragments/valid-query.gql new file mode 100644 index 00000000000..11d46b7cd37 --- /dev/null +++ b/packages/plugin/tests/mocks/import-fragments/valid-query.gql @@ -0,0 +1,9 @@ +# Imports could have extra whitespace and double/single quotes + +# import FooFields from "foo-fragment.gql" + +query { + foo { + ...FooFields + } +} diff --git a/packages/plugin/tests/require-import-fragment.spec.ts b/packages/plugin/tests/require-import-fragment.spec.ts new file mode 100644 index 00000000000..4a09f908372 --- /dev/null +++ b/packages/plugin/tests/require-import-fragment.spec.ts @@ -0,0 +1,73 @@ +import { join } from 'node:path'; +import { GraphQLInvalidTestCase, GraphQLRuleTester } from '../src'; +import { rule } from '../src/rules/require-import-fragment'; +import { Linter } from 'eslint'; +import ParserOptions = Linter.ParserOptions; + +const ruleTester = new GraphQLRuleTester(); + +function withMocks({ + name, + filename, + errors, +}: { + name: string; + filename: string; + errors?: GraphQLInvalidTestCase['errors']; +}): { + name: string; + filename: string; + code: string; + parserOptions: { + documents: ParserOptions['documents']; + }; + errors: any; +} { + return { + name, + filename, + code: ruleTester.fromMockFile(filename.split('/mocks')[1]), + parserOptions: { + documents: [ + filename, + join(__dirname, 'mocks/import-fragments/foo-fragment.gql'), + join(__dirname, 'mocks/import-fragments/bar-fragment.gql'), + ], + }, + errors, + }; +} + +ruleTester.runGraphQLTests('require-import-fragment', rule, { + valid: [ + withMocks({ + name: 'should not report with named import', + filename: join(__dirname, 'mocks/import-fragments/valid-query.gql'), + }), + withMocks({ + name: 'should not report with default import', + filename: join(__dirname, 'mocks/import-fragments/valid-query-default.gql'), + }), + withMocks({ + name: 'should not report fragments from the same file', + filename: join(__dirname, 'mocks/import-fragments/same-file.gql'), + }), + ], + invalid: [ + withMocks({ + name: 'should report with named import', + filename: join(__dirname, 'mocks/import-fragments/invalid-query.gql'), + errors: [{ message: 'Expected "FooFields" fragment to be imported.' }], + }), + withMocks({ + name: 'should report with default import', + filename: join(__dirname, 'mocks/import-fragments/invalid-query-default.gql'), + errors: [{ message: 'Expected "FooFields" fragment to be imported.' }], + }), + withMocks({ + name: 'should report fragments when there are no import expressions', + filename: join(__dirname, 'mocks/import-fragments/missing-import.gql'), + errors: [{ message: 'Expected "FooFields" fragment to be imported.' }], + }), + ], +}); diff --git a/website/src/pages/rules/_meta.json b/website/src/pages/rules/_meta.json index b53b504a0f1..f6ad280eaf1 100644 --- a/website/src/pages/rules/_meta.json +++ b/website/src/pages/rules/_meta.json @@ -58,6 +58,7 @@ "overlapping-fields-can-be-merged": "", "possible-fragment-spread": "", "require-id-when-available": "", + "require-import-fragment": "", "scalar-leafs": "", "selection-set-depth": "", "unique-argument-names": "", diff --git a/website/src/pages/rules/index.md b/website/src/pages/rules/index.md index 4dcefcc50e1..ee8413f8a76 100644 --- a/website/src/pages/rules/index.md +++ b/website/src/pages/rules/index.md @@ -57,6 +57,7 @@ Each rule has emojis denoting: | [require-description](/rules/require-description) | Enforce descriptions in type definitions and operations. | ![recommended][] | 📄 | 🚀 | | [require-field-of-type-query-in-mutation-result](/rules/require-field-of-type-query-in-mutation-result) | Allow the client in one round-trip not only to call mutation but also to get a wagon of data to update their application. | ![all][] | 📄 | 🚀 | | [require-id-when-available](/rules/require-id-when-available) | Enforce selecting specific fields when they are available on the GraphQL type. | ![recommended][] | đŸ“Ļ | 🚀 | 💡 | +| [require-import-fragment](/rules/require-import-fragment) | Require fragments to be imported via an import expression. | ![all][] | đŸ“Ļ | 🚀 | 💡 | | [require-nullable-fields-with-oneof](/rules/require-nullable-fields-with-oneof) | Require `input` or `type` fields to be non-nullable with `@oneOf` directive. | ![all][] | 📄 | 🚀 | | [require-type-pattern-with-oneof](/rules/require-type-pattern-with-oneof) | Enforce types with `@oneOf` directive have `error` and `ok` fields. | ![all][] | 📄 | 🚀 | | [scalar-leafs](/rules/scalar-leafs) | A GraphQL document is valid only if all leaf fields (fields without sub selections) are of scalar or enum types. | ![recommended][] | đŸ“Ļ | 🔮 | 💡 | diff --git a/website/src/pages/rules/require-import-fragment.md b/website/src/pages/rules/require-import-fragment.md new file mode 100644 index 00000000000..a87b4e9647d --- /dev/null +++ b/website/src/pages/rules/require-import-fragment.md @@ -0,0 +1,71 @@ +# `require-import-fragment` + +💡 This rule provides +[suggestions](https://eslint.org/docs/developer-guide/working-with-rules#providing-suggestions) + +- Category: `Operations` +- Rule name: `@graphql-eslint/require-import-fragment` +- Requires GraphQL Schema: `false` + [ℹī¸](/docs/getting-started#extended-linting-rules-with-graphql-schema) +- Requires GraphQL Operations: `true` + [ℹī¸](/docs/getting-started#extended-linting-rules-with-siblings-operations) + +Require fragments to be imported via an import expression. + +## Usage Examples + +### Incorrect + +```graphql +# eslint @graphql-eslint/require-import-fragment: 'error' + +query { + user { + ...UserFields + } +} +``` + +### Incorrect + +```graphql +# eslint @graphql-eslint/require-import-fragment: 'error' + +# import 'post-fields.fragment.graphql' +query { + user { + ...UserFields + } +} +``` + +### Incorrect + +```graphql +# eslint @graphql-eslint/require-import-fragment: 'error' + +# import UserFields from 'post-fields.fragment.graphql' +query { + user { + ...UserFields + } +} +``` + +### Correct + +```graphql +# eslint @graphql-eslint/require-import-fragment: 'error' + +# import UserFields from 'user-fields.fragment.graphql' +query { + user { + ...UserFields + } +} +``` + +## Resources + +- [Rule source](https://github.com/B2o5T/graphql-eslint/tree/master/packages/plugin/src/rules/require-import-fragment.ts) +- [Test source](https://github.com/B2o5T/graphql-eslint/tree/master/packages/plugin/tests/require-import-fragment.spec.ts)