@graphql-eslint/eslint-plugin
Version:
GraphQL plugin for ESLint
551 lines (541 loc) • 18.3 kB
JavaScript
Object.defineProperty(exports, "__esModule", {value: true}); function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }var _graphql = require('graphql');
var _utilsjs = require('../../utils.js');
const KindToDisplayName = {
// types
[_graphql.Kind.OBJECT_TYPE_DEFINITION]: "Type",
[_graphql.Kind.INTERFACE_TYPE_DEFINITION]: "Interface",
[_graphql.Kind.ENUM_TYPE_DEFINITION]: "Enumerator",
[_graphql.Kind.SCALAR_TYPE_DEFINITION]: "Scalar",
[_graphql.Kind.INPUT_OBJECT_TYPE_DEFINITION]: "Input type",
[_graphql.Kind.UNION_TYPE_DEFINITION]: "Union",
// fields
[_graphql.Kind.FIELD_DEFINITION]: "Field",
[_graphql.Kind.INPUT_VALUE_DEFINITION]: "Input property",
[_graphql.Kind.ARGUMENT]: "Argument",
[_graphql.Kind.DIRECTIVE_DEFINITION]: "Directive",
// rest
[_graphql.Kind.ENUM_VALUE_DEFINITION]: "Enumeration value",
[_graphql.Kind.OPERATION_DEFINITION]: "Operation",
[_graphql.Kind.FRAGMENT_DEFINITION]: "Fragment",
[_graphql.Kind.VARIABLE_DEFINITION]: "Variable"
};
const StyleToRegex = {
camelCase: /^[a-z][\dA-Za-z]*$/,
PascalCase: /^[A-Z][\dA-Za-z]*$/,
snake_case: /^[a-z][\d_a-z]*[\da-z]*$/,
UPPER_CASE: /^[A-Z][\dA-Z_]*[\dA-Z]*$/
};
const ALLOWED_KINDS = Object.keys(KindToDisplayName).sort();
const ALLOWED_STYLES = Object.keys(StyleToRegex);
const schemaOption = {
oneOf: [{ $ref: "#/definitions/asString" }, { $ref: "#/definitions/asObject" }]
};
const descriptionPrefixesSuffixes = (name, id) => `> [!WARNING]
>
> 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: caseSchema,
asObject: {
type: "object",
additionalProperties: false,
properties: {
style: caseSchema,
prefix: { type: "string" },
suffix: { type: "string" },
forbiddenPatterns: {
..._utilsjs.ARRAY_DEFAULT_OPTIONS,
items: {
type: "object"
},
description: "Should be of instance of `RegEx`"
},
requiredPattern: {
type: "object",
description: "Should be of instance of `RegEx`"
},
forbiddenPrefixes: {
..._utilsjs.ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes("forbiddenPatterns", "forbiddenpatterns-array")
},
forbiddenSuffixes: {
..._utilsjs.ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes("forbiddenPatterns", "forbiddenpatterns-array")
},
requiredPrefixes: {
..._utilsjs.ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes("requiredPattern", "requiredpattern-object")
},
requiredSuffixes: {
..._utilsjs.ARRAY_DEFAULT_OPTIONS,
description: descriptionPrefixesSuffixes("requiredPattern", "requiredpattern-object")
},
ignorePattern: {
type: "string",
description: "Option to skip validation of some words, e.g. acronyms"
}
}
}
},
type: "array",
maxItems: 1,
items: {
type: "object",
additionalProperties: false,
properties: {
types: {
...schemaOption,
description: `Includes:
${_utilsjs.TYPES_KINDS.map((kind) => `- \`${kind}\``).join("\n")}`
},
...Object.fromEntries(
ALLOWED_KINDS.map((kind) => [
kind,
{
...schemaOption,
description: `> [!NOTE]
>
> Read more about this kind on [spec.graphql.org](https://spec.graphql.org/October2021/#${kind}).`
}
])
),
allowLeadingUnderscore: {
type: "boolean",
default: false
},
allowTrailingUnderscore: {
type: "boolean",
default: false
}
},
patternProperties: {
[`^(${ALLOWED_KINDS.join("|")})(.+)?$`]: schemaOption
},
description: [
"> It's possible to use a [`selector`](https://eslint.org/docs/developer-guide/selectors) that starts with allowed `ASTNode` names which are described below.",
">",
"> Paste or drop code into the editor in [ASTExplorer](https://astexplorer.net) and inspect the generated AST to compose your selector.",
">",
"> Example: pattern property `FieldDefinition[parent.name.value=Query]` will match only fields for type `Query`."
].join("\n")
}
};
const rule = {
meta: {
type: "suggestion",
docs: {
description: "Require names to follow specified conventions.",
category: ["Schema", "Operations"],
recommended: true,
url: "https://the-guild.dev/graphql/eslint/rules/naming-convention",
examples: [
{
title: "Incorrect",
usage: [{ types: "PascalCase", FieldDefinition: "camelCase" }],
code: (
/* GraphQL */
`
type user {
first_name: String!
}
`
)
},
{
title: "Incorrect",
usage: [
{
FragmentDefinition: {
style: "PascalCase",
forbiddenPatterns: [/(^fragment)|(fragment$)/i]
}
}
],
code: (
/* GraphQL */
`
fragment UserFragment on User {
# ...
}
`
)
},
{
title: "Incorrect",
usage: [{ "FieldDefinition[parent.name.value=Query]": { forbiddenPatterns: [/^get/i] } }],
code: (
/* GraphQL */
`
type Query {
getUsers: [User!]!
}
`
)
},
{
title: "Correct",
usage: [{ types: "PascalCase", FieldDefinition: "camelCase" }],
code: (
/* GraphQL */
`
type User {
firstName: String
}
`
)
},
{
title: "Correct",
usage: [
{
FragmentDefinition: {
style: "PascalCase",
forbiddenPatterns: [/(^fragment)|(fragment$)/i]
}
}
],
code: (
/* GraphQL */
`
fragment UserFields on User {
# ...
}
`
)
},
{
title: "Correct",
usage: [{ "FieldDefinition[parent.name.value=Query]": { forbiddenPatterns: [/^get/i] } }],
code: (
/* GraphQL */
`
type Query {
users: [User!]!
}
`
)
},
{
title: "Correct",
usage: [{ FieldDefinition: { style: "camelCase", ignorePattern: "^(EAN13|UPC|UK)" } }],
code: (
/* GraphQL */
`
type Product {
EAN13: String
UPC: String
UKFlag: String
}
`
)
},
{
title: "Correct",
usage: [
{
"FieldDefinition[gqlType.name.value=Boolean]": {
style: "camelCase",
requiredPattern: /^(is|has)/
},
"FieldDefinition[gqlType.gqlType.name.value=Boolean]": {
style: "camelCase",
requiredPattern: /^(is|has)/
}
}
],
code: (
/* GraphQL */
`
type Product {
isBackordered: Boolean
isNew: Boolean!
hasDiscount: Boolean!
}
`
)
},
{
title: "Correct",
usage: [
{
"FieldDefinition[gqlType.gqlType.name.value=SensitiveSecret]": {
style: "camelCase",
requiredPattern: /SensitiveSecret$/
}
}
],
code: (
/* GraphQL */
`
scalar SensitiveSecret
type Account {
accountSensitiveSecret: SensitiveSecret!
}
`
)
},
{
title: "Correct (Relay fragment convention `<module_name>_<property_name>`)",
usage: [
{
FragmentDefinition: {
style: "PascalCase",
requiredPattern: /_(?<camelCase>.+?)$/
}
}
],
code: (
/* GraphQL */
`
# schema
type User {
# ...
}
# operations
fragment UserFields_data on User {
# ...
}
`
)
}
],
configOptions: {
schema: [
{
types: "PascalCase",
FieldDefinition: "camelCase",
InputValueDefinition: "camelCase",
Argument: "camelCase",
DirectiveDefinition: "camelCase",
EnumValueDefinition: "UPPER_CASE",
"FieldDefinition[parent.name.value=Query]": {
forbiddenPatterns: [/^(query|get)/i, /query$/i]
},
"FieldDefinition[parent.name.value=Mutation]": {
forbiddenPatterns: [/(^mutation)|(mutation$)/i]
},
"FieldDefinition[parent.name.value=Subscription]": {
forbiddenPatterns: [/(^subscription)|(subscription$)/i]
},
"EnumTypeDefinition,EnumTypeExtension": {
forbiddenPatterns: [/(^enum)|(enum$)/i]
},
"InterfaceTypeDefinition,InterfaceTypeExtension": {
forbiddenPatterns: [/(^interface)|(interface$)/i]
},
"UnionTypeDefinition,UnionTypeExtension": {
forbiddenPatterns: [/(^union)|(union$)/i]
},
"ObjectTypeDefinition,ObjectTypeExtension": {
forbiddenPatterns: [/(^type)|(type$)/i]
}
}
],
operations: [
{
VariableDefinition: "camelCase",
OperationDefinition: {
style: "PascalCase",
forbiddenPatterns: [
/^(query|mutation|subscription|get)/i,
/(query|mutation|subscription)$/i
]
},
FragmentDefinition: {
style: "PascalCase",
forbiddenPatterns: [/(^fragment)|(fragment$)/i]
}
}
]
}
},
hasSuggestions: true,
schema
},
create(context) {
const options = context.options[0] || {};
const { allowLeadingUnderscore, allowTrailingUnderscore, types, ...restOptions } = options;
const ignoredNodes = /* @__PURE__ */ new Set();
function normalisePropertyOption(kind) {
const style = restOptions[kind] || types;
return typeof style === "object" ? style : { style };
}
function report(node, message, suggestedNames) {
context.report({
node,
message,
suggest: suggestedNames.map((suggestedName) => ({
desc: `Rename to \`${suggestedName}\``,
fix: (fixer) => fixer.replaceText(node, suggestedName)
}))
});
}
const checkNode = (selector) => (n) => {
const { name: node } = n.kind === _graphql.Kind.VARIABLE_DEFINITION ? n.variable : n;
if (!node) {
return;
}
const {
prefix,
suffix,
forbiddenPrefixes,
forbiddenSuffixes,
style,
ignorePattern,
requiredPrefixes,
requiredSuffixes,
forbiddenPatterns,
requiredPattern
} = normalisePropertyOption(selector);
const nodeName = node.value;
const error = getError();
if (error) {
const { errorMessage, renameToNames } = error;
const [leadingUnderscores] = nodeName.match(/^_*/);
const [trailingUnderscores] = nodeName.match(/_*$/);
const suggestedNames = renameToNames.map(
(renameToName) => leadingUnderscores + renameToName + trailingUnderscores
);
const name = _utilsjs.displayNodeName.call(void 0, n);
report(
node,
`${name[0].toUpperCase()}${name.slice(1)} should ${errorMessage}`,
suggestedNames
);
}
function getError() {
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);
}
return;
}
if (prefix && !name.startsWith(prefix)) {
return {
errorMessage: `have "${prefix}" prefix`,
renameToNames: [prefix + name]
};
}
if (suffix && !name.endsWith(suffix)) {
return {
errorMessage: `have "${suffix}" suffix`,
renameToNames: [name + suffix]
};
}
if (requiredPattern) {
if (requiredPattern.source.includes("(?<")) {
try {
name = name.replace(requiredPattern, (originalString, ...args) => {
const groups = args.at(-1);
for (const [styleName, value] of Object.entries(groups)) {
if (!(styleName in StyleToRegex)) {
throw new Error("Invalid case style in `requiredPatterns` option");
}
if (value === _utilsjs.convertCase.call(void 0, styleName, value)) {
return "";
}
throw new Error(`contain the required pattern: ${requiredPattern}`);
}
return originalString;
});
if (name === nodeName) {
throw new Error(`contain the required pattern: ${requiredPattern}`);
}
} catch (error2) {
return {
errorMessage: error2.message,
renameToNames: []
};
}
} else if (!requiredPattern.test(name)) {
return {
errorMessage: `contain the required pattern: ${requiredPattern}`,
renameToNames: []
};
}
}
const forbidden = _optionalChain([forbiddenPatterns, 'optionalAccess', _ => _.find, 'call', _2 => _2((pattern) => pattern.test(name))]);
if (forbidden) {
return {
errorMessage: `not contain the forbidden pattern "${forbidden}"`,
renameToNames: [name.replace(forbidden, "")]
};
}
const forbiddenPrefix = _optionalChain([forbiddenPrefixes, 'optionalAccess', _3 => _3.find, 'call', _4 => _4((prefix2) => name.startsWith(prefix2))]);
if (forbiddenPrefix) {
return {
errorMessage: `not have "${forbiddenPrefix}" prefix`,
renameToNames: [name.replace(new RegExp(`^${forbiddenPrefix}`), "")]
};
}
const forbiddenSuffix = _optionalChain([forbiddenSuffixes, 'optionalAccess', _5 => _5.find, 'call', _6 => _6((suffix2) => name.endsWith(suffix2))]);
if (forbiddenSuffix) {
return {
errorMessage: `not have "${forbiddenSuffix}" suffix`,
renameToNames: [name.replace(new RegExp(`${forbiddenSuffix}$`), "")]
};
}
if (requiredPrefixes && !requiredPrefixes.some((requiredPrefix) => name.startsWith(requiredPrefix))) {
return {
errorMessage: `have one of the following prefixes: ${_utilsjs.englishJoinWords.call(void 0,
requiredPrefixes
)}`,
renameToNames: style ? requiredPrefixes.map((prefix2) => _utilsjs.convertCase.call(void 0, style, `${prefix2} ${name}`)) : requiredPrefixes.map((prefix2) => `${prefix2}${name}`)
};
}
if (requiredSuffixes && !requiredSuffixes.some((requiredSuffix) => name.endsWith(requiredSuffix))) {
return {
errorMessage: `have one of the following suffixes: ${_utilsjs.englishJoinWords.call(void 0,
requiredSuffixes
)}`,
renameToNames: style ? requiredSuffixes.map((suffix2) => _utilsjs.convertCase.call(void 0, style, `${name} ${suffix2}`)) : requiredSuffixes.map((suffix2) => `${name}${suffix2}`)
};
}
if (!style) {
return;
}
const caseRegex = StyleToRegex[style];
if (!caseRegex.test(name)) {
return {
errorMessage: `be in ${style} format`,
renameToNames: [_utilsjs.convertCase.call(void 0, style, name)]
};
}
}
};
const checkUnderscore = (isLeading) => (node) => {
if (ignoredNodes.has(node)) {
return;
}
if (node.parent.kind === "Field" && node.parent.alias !== node) {
return;
}
const suggestedName = node.value.replace(isLeading ? /^_+/ : /_+$/, "");
report(node, `${isLeading ? "Leading" : "Trailing"} underscores are not allowed`, [
suggestedName
]);
};
const listeners = {};
if (!allowLeadingUnderscore) {
listeners["Name[value=/^_/]"] = checkUnderscore(true);
}
if (!allowTrailingUnderscore) {
listeners["Name[value=/_$/]"] = checkUnderscore(false);
}
const selectors = new Set(
[types && _utilsjs.TYPES_KINDS, Object.keys(restOptions)].filter((v) => !!v).flat()
);
for (const selector of selectors) {
listeners[selector] = checkNode(selector);
}
return listeners;
}
};
exports.rule = rule;
;