UNPKG

@graphql-eslint/eslint-plugin

Version:
551 lines (541 loc) • 18.3 kB
"use strict";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;