@graphql-eslint/eslint-plugin
Version:
GraphQL plugin for ESLint
502 lines (499 loc) • 19.3 kB
JavaScript
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
import { Kind, visit, validate, } from 'graphql';
import { validateSDL } from 'graphql/validation/validate.js';
import { requireGraphQLSchemaFromContext, requireSiblingsOperations, logger, REPORT_ON_FIRST_CHARACTER, ARRAY_DEFAULT_OPTIONS, } from '../utils.js';
function validateDocument({ context, schema = null, documentNode, rule, hasDidYouMeanSuggestions, }) {
var _a;
if (documentNode.definitions.length === 0) {
return;
}
try {
const validationErrors = schema
? validate(schema, documentNode, [rule])
: validateSDL(documentNode, null, [rule]);
for (const error of validationErrors) {
const { line, column } = error.locations[0];
const sourceCode = context.getSourceCode();
const { tokens } = sourceCode.ast;
const token = tokens.find(token => token.loc.start.line === line && token.loc.start.column === column - 1);
let loc = {
line,
column: column - 1,
};
if (token) {
loc =
// if cursor on `@` symbol than use next node
token.type === '@'
? sourceCode.getNodeByRangeIndex(token.range[1] + 1).loc
: token.loc;
}
const didYouMeanContent = (_a = error.message.match(/Did you mean (?<content>.*)\?$/)) === null || _a === void 0 ? void 0 : _a.groups.content;
const matches = didYouMeanContent ? [...didYouMeanContent.matchAll(/"(?<name>[^"]*)"/g)] : [];
context.report({
loc,
message: error.message,
suggest: hasDidYouMeanSuggestions
? matches.map(match => {
const { name } = match.groups;
return {
desc: `Rename to \`${name}\``,
fix: fixer => fixer.replaceText(token, name),
};
})
: [],
});
}
}
catch (error) {
context.report({
loc: REPORT_ON_FIRST_CHARACTER,
message: error.message,
});
}
}
const getFragmentDefsAndFragmentSpreads = (node) => {
const fragmentDefs = new Set();
const fragmentSpreads = new Set();
const visitor = {
FragmentDefinition(node) {
fragmentDefs.add(node.name.value);
},
FragmentSpread(node) {
fragmentSpreads.add(node.name.value);
},
};
visit(node, visitor);
return { fragmentDefs, fragmentSpreads };
};
const getMissingFragments = (node) => {
const { fragmentDefs, fragmentSpreads } = getFragmentDefsAndFragmentSpreads(node);
return [...fragmentSpreads].filter(name => !fragmentDefs.has(name));
};
const handleMissingFragments = ({ ruleId, context, node }) => {
const missingFragments = getMissingFragments(node);
if (missingFragments.length > 0) {
const siblings = requireSiblingsOperations(ruleId, context);
const fragmentsToAdd = [];
for (const fragmentName of missingFragments) {
const [foundFragment] = siblings.getFragment(fragmentName).map(source => source.document);
if (foundFragment) {
fragmentsToAdd.push(foundFragment);
}
}
if (fragmentsToAdd.length > 0) {
// recall fn to make sure to add fragments inside fragments
return handleMissingFragments({
ruleId,
context,
node: {
kind: Kind.DOCUMENT,
definitions: [...node.definitions, ...fragmentsToAdd],
},
});
}
}
return node;
};
const validationToRule = ({ ruleId, ruleName, getDocumentNode, schema = [], hasDidYouMeanSuggestions, }, docs) => {
let ruleFn = null;
try {
ruleFn = require(`graphql/validation/rules/${ruleName}Rule`)[`${ruleName}Rule`];
}
catch (_a) {
try {
ruleFn = require(`graphql/validation/rules/${ruleName}`)[`${ruleName}Rule`];
}
catch (_b) {
ruleFn = require('graphql/validation')[`${ruleName}Rule`];
}
}
return {
[ruleId]: {
meta: {
docs: {
recommended: true,
...docs,
graphQLJSRuleName: ruleName,
url: `https://github.com/B2o5T/graphql-eslint/blob/master/docs/rules/${ruleId}.md`,
description: `${docs.description}\n> This rule is a wrapper around a \`graphql-js\` validation function.`,
},
schema,
hasSuggestions: hasDidYouMeanSuggestions,
},
create(context) {
if (!ruleFn) {
logger.warn(`Rule "${ruleId}" depends on a GraphQL validation rule "${ruleName}" but it's not available in the "graphql" version you are using. Skipping…`);
return {};
}
return {
Document(node) {
const schema = docs.requiresSchema
? requireGraphQLSchemaFromContext(ruleId, context)
: null;
const documentNode = getDocumentNode
? getDocumentNode({ ruleId, context, node: node.rawNode() })
: node.rawNode();
validateDocument({
context,
schema,
documentNode,
rule: ruleFn,
hasDidYouMeanSuggestions,
});
},
};
},
},
};
};
export const GRAPHQL_JS_VALIDATIONS = Object.assign({}, validationToRule({
ruleId: 'executable-definitions',
ruleName: 'ExecutableDefinitions',
}, {
category: 'Operations',
description: 'A GraphQL document is only valid for execution if all definitions are either operation or fragment definitions.',
requiresSchema: true,
}), validationToRule({
ruleId: 'fields-on-correct-type',
ruleName: 'FieldsOnCorrectType',
hasDidYouMeanSuggestions: true,
}, {
category: 'Operations',
description: 'A GraphQL document is only valid if all fields selected are defined by the parent type, or are an allowed meta field such as `__typename`.',
requiresSchema: true,
}), validationToRule({
ruleId: 'fragments-on-composite-type',
ruleName: 'FragmentsOnCompositeTypes',
}, {
category: 'Operations',
description: 'Fragments use a type condition to determine if they apply, since fragments can only be spread into a composite type (object, interface, or union), the type condition must also be a composite type.',
requiresSchema: true,
}), validationToRule({
ruleId: 'known-argument-names',
ruleName: 'KnownArgumentNames',
hasDidYouMeanSuggestions: true,
}, {
category: ['Schema', 'Operations'],
description: 'A GraphQL field is only valid if all supplied arguments are defined by that field.',
requiresSchema: true,
}), validationToRule({
ruleId: 'known-directives',
ruleName: 'KnownDirectives',
getDocumentNode({ context, node: documentNode }) {
const { ignoreClientDirectives = [] } = context.options[0] || {};
if (ignoreClientDirectives.length === 0) {
return documentNode;
}
const filterDirectives = (node) => {
var _a;
return ({
...node,
directives: (_a = node.directives) === null || _a === void 0 ? void 0 : _a.filter(directive => !ignoreClientDirectives.includes(directive.name.value)),
});
};
return visit(documentNode, {
Field: filterDirectives,
OperationDefinition: filterDirectives,
});
},
schema: {
type: 'array',
maxItems: 1,
items: {
type: 'object',
additionalProperties: false,
required: ['ignoreClientDirectives'],
properties: {
ignoreClientDirectives: ARRAY_DEFAULT_OPTIONS,
},
},
},
}, {
category: ['Schema', 'Operations'],
description: 'A GraphQL document is only valid if all `@directive`s are known by the schema and legally positioned.',
requiresSchema: true,
examples: [
{
title: 'Valid',
usage: [{ ignoreClientDirectives: ['client'] }],
code: /* GraphQL */ `
{
product {
someClientField @client
}
}
`,
},
],
}), validationToRule({
ruleId: 'known-fragment-names',
ruleName: 'KnownFragmentNames',
getDocumentNode: handleMissingFragments,
}, {
category: 'Operations',
description: 'A GraphQL document is only valid if all `...Fragment` fragment spreads refer to fragments defined in the same document.',
requiresSchema: true,
requiresSiblings: true,
examples: [
{
title: 'Incorrect',
code: /* GraphQL */ `
query {
user {
id
...UserFields # fragment not defined in the document
}
}
`,
},
{
title: 'Correct',
code: /* GraphQL */ `
fragment UserFields on User {
firstName
lastName
}
query {
user {
id
...UserFields
}
}
`,
},
{
title: 'Correct (`UserFields` fragment located in a separate file)',
code: /* GraphQL */ `
# user.gql
query {
user {
id
...UserFields
}
}
# user-fields.gql
fragment UserFields on User {
id
}
`,
},
],
}), validationToRule({
ruleId: 'known-type-names',
ruleName: 'KnownTypeNames',
hasDidYouMeanSuggestions: true,
}, {
category: ['Schema', 'Operations'],
description: 'A GraphQL document is only valid if referenced types (specifically variable definitions and fragment conditions) are defined by the type schema.',
requiresSchema: true,
}), validationToRule({
ruleId: 'lone-anonymous-operation',
ruleName: 'LoneAnonymousOperation',
}, {
category: 'Operations',
description: 'A GraphQL document that contains an anonymous operation (the `query` short-hand) is only valid if it contains only that one operation definition.',
requiresSchema: true,
}), validationToRule({
ruleId: 'lone-schema-definition',
ruleName: 'LoneSchemaDefinition',
}, {
category: 'Schema',
description: 'A GraphQL document is only valid if it contains only one schema definition.',
}), validationToRule({
ruleId: 'no-fragment-cycles',
ruleName: 'NoFragmentCycles',
}, {
category: 'Operations',
description: 'A GraphQL fragment is only valid when it does not have cycles in fragments usage.',
requiresSchema: true,
}), validationToRule({
ruleId: 'no-undefined-variables',
ruleName: 'NoUndefinedVariables',
getDocumentNode: handleMissingFragments,
}, {
category: 'Operations',
description: 'A GraphQL operation is only valid if all variables encountered, both directly and via fragment spreads, are defined by that operation.',
requiresSchema: true,
requiresSiblings: true,
}), validationToRule({
ruleId: 'no-unused-fragments',
ruleName: 'NoUnusedFragments',
getDocumentNode: ({ ruleId, context, node }) => {
const siblings = requireSiblingsOperations(ruleId, context);
const FilePathToDocumentsMap = [
...siblings.getOperations(),
...siblings.getFragments(),
].reduce((map, { filePath, document }) => {
var _a;
(_a = map[filePath]) !== null && _a !== void 0 ? _a : (map[filePath] = []);
map[filePath].push(document);
return map;
}, Object.create(null));
const getParentNode = (currentFilePath, node) => {
const { fragmentDefs } = getFragmentDefsAndFragmentSpreads(node);
if (fragmentDefs.size === 0) {
return node;
}
// skip iteration over documents for current filepath
delete FilePathToDocumentsMap[currentFilePath];
for (const [filePath, documents] of Object.entries(FilePathToDocumentsMap)) {
const missingFragments = getMissingFragments({
kind: Kind.DOCUMENT,
definitions: documents,
});
const isCurrentFileImportFragment = missingFragments.some(fragment => fragmentDefs.has(fragment));
if (isCurrentFileImportFragment) {
return getParentNode(filePath, {
kind: Kind.DOCUMENT,
definitions: [...node.definitions, ...documents],
});
}
}
return node;
};
return getParentNode(context.getFilename(), node);
},
}, {
category: 'Operations',
description: 'A GraphQL document is only valid if all fragment definitions are spread within operations, or spread within other fragments spread within operations.',
requiresSchema: true,
requiresSiblings: true,
}), validationToRule({
ruleId: 'no-unused-variables',
ruleName: 'NoUnusedVariables',
getDocumentNode: handleMissingFragments,
}, {
category: 'Operations',
description: 'A GraphQL operation is only valid if all variables defined by an operation are used, either directly or within a spread fragment.',
requiresSchema: true,
requiresSiblings: true,
}), validationToRule({
ruleId: 'overlapping-fields-can-be-merged',
ruleName: 'OverlappingFieldsCanBeMerged',
}, {
category: 'Operations',
description: 'A selection set is only valid if all fields (including spreading any fragments) either correspond to distinct response names or can be merged without ambiguity.',
requiresSchema: true,
}), validationToRule({
ruleId: 'possible-fragment-spread',
ruleName: 'PossibleFragmentSpreads',
}, {
category: 'Operations',
description: 'A fragment spread is only valid if the type condition could ever possibly be true: if there is a non-empty intersection of the possible parent types, and possible types which pass the type condition.',
requiresSchema: true,
}), validationToRule({
ruleId: 'possible-type-extension',
ruleName: 'PossibleTypeExtensions',
hasDidYouMeanSuggestions: true,
}, {
category: 'Schema',
description: 'A type extension is only valid if the type is defined and has the same kind.',
// TODO: add in graphql-eslint v4
recommended: false,
requiresSchema: true,
isDisabledForAllConfig: true,
}), validationToRule({
ruleId: 'provided-required-arguments',
ruleName: 'ProvidedRequiredArguments',
}, {
category: ['Schema', 'Operations'],
description: 'A field or directive is only valid if all required (non-null without a default value) field arguments have been provided.',
requiresSchema: true,
}), validationToRule({
ruleId: 'scalar-leafs',
ruleName: 'ScalarLeafs',
hasDidYouMeanSuggestions: true,
}, {
category: 'Operations',
description: 'A GraphQL document is valid only if all leaf fields (fields without sub selections) are of scalar or enum types.',
requiresSchema: true,
}), validationToRule({
ruleId: 'one-field-subscriptions',
ruleName: 'SingleFieldSubscriptions',
}, {
category: 'Operations',
description: 'A GraphQL subscription is valid only if it contains a single root field.',
requiresSchema: true,
}), validationToRule({
ruleId: 'unique-argument-names',
ruleName: 'UniqueArgumentNames',
}, {
category: 'Operations',
description: 'A GraphQL field or directive is only valid if all supplied arguments are uniquely named.',
requiresSchema: true,
}), validationToRule({
ruleId: 'unique-directive-names',
ruleName: 'UniqueDirectiveNames',
}, {
category: 'Schema',
description: 'A GraphQL document is only valid if all defined directives have unique names.',
}), validationToRule({
ruleId: 'unique-directive-names-per-location',
ruleName: 'UniqueDirectivesPerLocation',
}, {
category: ['Schema', 'Operations'],
description: 'A GraphQL document is only valid if all non-repeatable directives at a given location are uniquely named.',
requiresSchema: true,
}), validationToRule({
ruleId: 'unique-enum-value-names',
ruleName: 'UniqueEnumValueNames',
}, {
category: 'Schema',
description: 'A GraphQL enum type is only valid if all its values are uniquely named.',
recommended: false,
isDisabledForAllConfig: true,
}), validationToRule({
ruleId: 'unique-field-definition-names',
ruleName: 'UniqueFieldDefinitionNames',
}, {
category: 'Schema',
description: 'A GraphQL complex type is only valid if all its fields are uniquely named.',
}), validationToRule({
ruleId: 'unique-input-field-names',
ruleName: 'UniqueInputFieldNames',
}, {
category: 'Operations',
description: 'A GraphQL input object value is only valid if all supplied fields are uniquely named.',
}), validationToRule({
ruleId: 'unique-operation-types',
ruleName: 'UniqueOperationTypes',
}, {
category: 'Schema',
description: 'A GraphQL document is only valid if it has only one type per operation.',
}), validationToRule({
ruleId: 'unique-type-names',
ruleName: 'UniqueTypeNames',
}, {
category: 'Schema',
description: 'A GraphQL document is only valid if all defined types have unique names.',
}), validationToRule({
ruleId: 'unique-variable-names',
ruleName: 'UniqueVariableNames',
}, {
category: 'Operations',
description: 'A GraphQL operation is only valid if all its variables are uniquely named.',
requiresSchema: true,
}), validationToRule({
ruleId: 'value-literals-of-correct-type',
ruleName: 'ValuesOfCorrectType',
hasDidYouMeanSuggestions: true,
}, {
category: 'Operations',
description: 'A GraphQL document is only valid if all value literals are of the type expected at their position.',
requiresSchema: true,
}), validationToRule({
ruleId: 'variables-are-input-types',
ruleName: 'VariablesAreInputTypes',
}, {
category: 'Operations',
description: 'A GraphQL operation is only valid if all the variables it defines are of input types (scalar, enum, or input object).',
requiresSchema: true,
}), validationToRule({
ruleId: 'variables-in-allowed-position',
ruleName: 'VariablesInAllowedPosition',
}, {
category: 'Operations',
description: 'Variables passed to field arguments conform to type.',
requiresSchema: true,
}));