Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add require-import-fragment rule #1443

Merged
merged 15 commits into from
Feb 9, 2023
6 changes: 6 additions & 0 deletions .changeset/spotty-meals-think.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions packages/plugin/src/configs/operations-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export default {
},
],
'@graphql-eslint/no-one-place-fragments': 'error',
'@graphql-eslint/require-import-fragment': 'error',
'@graphql-eslint/unique-fragment-name': 'error',
'@graphql-eslint/unique-operation-name': 'error',
},
Expand Down
2 changes: 2 additions & 0 deletions packages/plugin/src/rules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/plugin/src/rules/lone-executable-definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ export const rule: GraphQLESLintRule<RuleOptions> = {
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;
Expand Down
126 changes: 126 additions & 0 deletions packages/plugin/src/rules/require-import-fragment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
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',
FloEdelmann marked this conversation as resolved.
Show resolved Hide resolved
description: 'Require fragments to be imported via an import expression.',
dimaMachina marked this conversation as resolved.
Show resolved Hide resolved
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,
},
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<NameNode>) {
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 => importPath === source.filePath,
);
if (hasInSiblings) return;
}

const fragmentInSameFile = fragmentsFromSiblings.some(
source => source.filePath === filePath,
);
if (fragmentInSameFile) return;

context.report({
node,
messageId: RULE_ID,
data: { fragmentName },
suggest: (fragmentsFromSiblings.length
? fragmentsFromSiblings.map(o => path.relative(path.dirname(filePath), o.filePath))
: ['CHANGE_ME.graphql']
).map(suggestedPath => ({
messageId: SUGGESTION_ID,
data: { fragmentName },
fix: fixer =>
fixer.insertTextBeforeRange(
[0, 0],
`# import ${fragmentName} from '${suggestedPath}'\n`,
),
})),
});
},
};
},
};
4 changes: 2 additions & 2 deletions packages/plugin/src/testkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ export type GraphQLESLintRuleListener<WithTypeInfo extends boolean = false> = Re
[K in keyof ASTKindToNode]?: (node: GraphQLESTreeNode<ASTKindToNode[K], WithTypeInfo>) => void;
};

export type GraphQLValidTestCase<Options> = Omit<
export type GraphQLValidTestCase<Options = []> = Omit<
RuleTester.ValidTestCase,
'options' | 'parserOptions'
> & {
options?: Options;
parserOptions?: Omit<ParserOptions, 'filePath'>;
};

export type GraphQLInvalidTestCase<T> = GraphQLValidTestCase<T> & {
export type GraphQLInvalidTestCase<T = []> = GraphQLValidTestCase<T> & {
errors: (RuleTester.TestCaseError | string)[] | number;
output?: string | null;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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 | }
`;
3 changes: 3 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/bar-fragment.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fragment BarFields on Bar {
id
}
3 changes: 3 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/foo-fragment.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
fragment FooFields on Foo {
id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import 'bar-fragment.gql'
query {
foo {
...FooFields
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
#import FooFields from "bar-fragment.gql"
query {
foo {
...FooFields
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
foo {
...FooFields
}
}
9 changes: 9 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/same-file.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
foo {
...FooFields
}
}

fragment FooFields on Foo {
id
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Imports could have extra whitespace and double/single quotes

# import 'foo-fragment.gql'

query {
foo {
...FooFields
}
}
9 changes: 9 additions & 0 deletions packages/plugin/tests/mocks/import-fragments/valid-query.gql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Imports could have extra whitespace and double/single quotes

# import FooFields from "foo-fragment.gql"

query {
foo {
...FooFields
}
}
Loading