├── .npmignore ├── test ├── fixtures │ ├── schema │ │ ├── empty.graphql │ │ ├── comment.graphql │ │ ├── schema.graphql │ │ ├── obvious.graphql │ │ ├── user.graphql │ │ └── inline-config.graphql │ ├── invalid.graphql │ ├── schema.graphql │ ├── invalid-query-root.graphql │ ├── schema.missing-query-root.graphql │ ├── schema.comment-descriptions.graphql │ ├── invalid-schema.graphql │ ├── invalid-ast.graphql │ ├── valid.graphql │ ├── custom_rules │ │ ├── many_rules.js │ │ ├── type_name_cannot_contain_type.js │ │ └── enum_name_cannot_contain_enum.js │ ├── animal.graphql │ ├── schema.new-implements.graphql │ └── schema.old-implements.graphql ├── config │ ├── rc_file │ │ ├── .graphql-schema-linterrc │ │ └── test.js │ ├── js_file │ │ ├── graphql-schema-linter.config.js │ │ └── test.js │ └── package_json │ │ └── test.js ├── strip_ansi.js ├── formatters │ ├── compact_formatter.js │ ├── json_formatter.js │ └── text_formatter.js ├── rules │ ├── enum_values_all_caps.js │ ├── types_are_capitalized.js │ ├── input_object_values_are_camel_cased.js │ ├── fields_are_camel_cased.js │ ├── enum_values_have_descriptions.js │ ├── fields_have_descriptions.js │ ├── input_object_values_have_descriptions.js │ ├── arguments_have_descriptions.js │ ├── deprecations_have_a_reason.js │ ├── descriptions_are_capitalized.js │ ├── interface_fields_sorted_alphabetically.js │ ├── enum_values_sorted_alphabetically.js │ ├── type_fields_sorted_alphabetically.js │ ├── input_object_fields_sorted_alphabetically.js │ ├── relay_page_info_spec.js │ ├── types_have_descriptions.js │ ├── defined_types_are_used.js │ ├── relay_connection_types_spec.js │ └── relay_connection_arguments_spec.js ├── index.js ├── source_map.js ├── helpers.js ├── schema.js ├── assertions.js ├── inline_configuration.js ├── find_schema_nodes.js ├── validator.js ├── configuration.js └── runner.js ├── .gitignore ├── renovate.json ├── screenshot-v0.0.24.png ├── .travis.yml ├── src ├── index.js ├── validation_error.js ├── util │ ├── getDescription.js │ ├── arraysEqual.js │ ├── expandPaths.js │ └── listIsAlphabetical.js ├── figures.js ├── rules │ ├── enum_values_all_caps.js │ ├── fields_are_camel_cased.js │ ├── fields_have_descriptions.js │ ├── input_object_values_are_camel_cased.js │ ├── enum_values_have_descriptions.js │ ├── arguments_have_descriptions.js │ ├── types_are_capitalized.js │ ├── type_fields_sorted_alphabetically.js │ ├── input_object_fields_sorted_alphabetically.js │ ├── interface_fields_sorted_alphabetically.js │ ├── enum_values_sorted_alphabetically.js │ ├── input_object_values_have_descriptions.js │ ├── descriptions_are_capitalized.js │ ├── types_have_descriptions.js │ ├── defined_types_are_used.js │ ├── deprecations_have_a_reason.js │ ├── relay_page_info_spec.js │ ├── relay_connection_types_spec.js │ └── relay_connection_arguments_spec.js ├── formatters │ ├── json_formatter.js │ ├── compact_formatter.js │ └── text_formatter.js ├── cli.js ├── find_schema_nodes.js ├── options.js ├── inline_configuration.js ├── source_map.js ├── schema.js ├── validator.js ├── configuration.js └── runner.js ├── LICENSE.txt ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | -------------------------------------------------------------------------------- /test/fixtures/schema/empty.graphql: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .vscode/ 4 | -------------------------------------------------------------------------------- /test/fixtures/invalid.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | -------------------------------------------------------------------------------- /test/fixtures/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | a: String! 3 | } 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/invalid-query-root.graphql: -------------------------------------------------------------------------------- 1 | interface Query { 2 | a: String 3 | } 4 | -------------------------------------------------------------------------------- /test/fixtures/schema/comment.graphql: -------------------------------------------------------------------------------- 1 | type Comment { 2 | body: String! 3 | author: User! 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/schema.missing-query-root.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | id: ID! 3 | email: String! 4 | } 5 | -------------------------------------------------------------------------------- /screenshot-v0.0.24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cjoudrey/graphql-schema-linter/HEAD/screenshot-v0.0.24.png -------------------------------------------------------------------------------- /test/fixtures/schema/schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | something: String! 3 | } 4 | 5 | schema { 6 | query: Query 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/schema.comment-descriptions.graphql: -------------------------------------------------------------------------------- 1 | # Query 2 | type Query { 3 | a: String 4 | 5 | # B 6 | b: String 7 | } 8 | -------------------------------------------------------------------------------- /test/fixtures/invalid-schema.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | a: String 3 | b: Node 4 | } 5 | 6 | type Another { 7 | b: Product 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/schema/obvious.graphql: -------------------------------------------------------------------------------- 1 | type Obvious { 2 | one: String! 3 | two: Int 4 | } 5 | 6 | type DontPanic { 7 | obvious: Boolean 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/schema/user.graphql: -------------------------------------------------------------------------------- 1 | type User { 2 | username: String! 3 | email: String! 4 | } 5 | 6 | extend type Query { 7 | viewer: User! 8 | } 9 | -------------------------------------------------------------------------------- /test/fixtures/invalid-ast.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | a: String 3 | } 4 | 5 | schema { 6 | query: Query 7 | } 8 | 9 | schema { 10 | mutation: Query 11 | } 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14" 4 | - "16" 5 | - "17" 6 | before_script: 7 | - yarn prepare 8 | script: 9 | - yarn test:ci 10 | - yarn prettier --check src/**/*.js 11 | notifications: 12 | email: false 13 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | module.exports.runner = require('./runner.js'); 2 | module.exports.validator = require('./validator.js'); 3 | module.exports.configuration = require('./configuration.js'); 4 | module.exports.ValidationError = require('./validation_error.js').ValidationError; 5 | -------------------------------------------------------------------------------- /src/validation_error.js: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql/error'; 2 | 3 | export class ValidationError extends GraphQLError { 4 | constructor(ruleName, message, nodes) { 5 | super(message, nodes); 6 | 7 | this.ruleName = ruleName; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/util/getDescription.js: -------------------------------------------------------------------------------- 1 | import { getDescription as legacyGetDescription } from 'graphql/utilities/extendSchema'; 2 | 3 | export function getDescription(node, options) { 4 | return legacyGetDescription 5 | ? legacyGetDescription(node, options) 6 | : node.description?.value; 7 | } 8 | -------------------------------------------------------------------------------- /src/util/arraysEqual.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @summary Returns `true` if two arrays have the same item values in the same order. 3 | */ 4 | export default function arraysEqual(a, b) { 5 | for (var i = 0; i < a.length; ++i) { 6 | if (a[i] !== b[i]) return false; 7 | } 8 | return true; 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/valid.graphql: -------------------------------------------------------------------------------- 1 | "Query" 2 | type Query { 3 | "A" 4 | a: String! 5 | 6 | "Something" 7 | something: PageInfo! 8 | } 9 | 10 | "PageInfo" 11 | type PageInfo { 12 | "HasNextPage" 13 | hasNextPage: Boolean! 14 | 15 | "HasPreviousPage" 16 | hasPreviousPage: Boolean! 17 | } 18 | -------------------------------------------------------------------------------- /test/fixtures/custom_rules/many_rules.js: -------------------------------------------------------------------------------- 1 | export function SomeRule(context) { 2 | return { 3 | EnumValueDefinition(node, key, parent, path, ancestors) {}, 4 | }; 5 | } 6 | 7 | export function AnotherRule(context) { 8 | return { 9 | EnumValueDefinition(node, key, parent, path, ancestors) {}, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/figures.js: -------------------------------------------------------------------------------- 1 | const { platform } = process; 2 | 3 | const main = { 4 | tick: '✔', 5 | cross: '✖', 6 | warning: '⚠', 7 | }; 8 | 9 | const windows = { 10 | tick: '√', 11 | cross: '×', 12 | warning: '‼', 13 | }; 14 | 15 | const figures = platform === 'win32' ? windows : main; 16 | 17 | module.exports = figures; 18 | -------------------------------------------------------------------------------- /test/config/rc_file/.graphql-schema-linterrc: -------------------------------------------------------------------------------- 1 | { 2 | "rules": ["enum-values-sorted-alphabetically"], 3 | "rulesOptions": { 4 | "enum-values-sorted-alphabetically": { 5 | "sortOrder": "lexicographical" 6 | } 7 | }, 8 | "ignore": {"fields-have-descriptions": ["Obvious", "Query.obvious", "Query.something.obvious"]}, 9 | } 10 | -------------------------------------------------------------------------------- /test/fixtures/schema/inline-config.graphql: -------------------------------------------------------------------------------- 1 | # lint-disable fields-have-descriptions 2 | extend type Query { 3 | comments: [Comment!]! 4 | } 5 | # lint-enable fields-have-descriptions 6 | 7 | type Post { 8 | id: ID! 9 | title: String! # lint-disable-line fields-have-descriptions 10 | description: String! 11 | author: User! 12 | } 13 | -------------------------------------------------------------------------------- /test/fixtures/animal.graphql: -------------------------------------------------------------------------------- 1 | "Base query" 2 | type Query { 3 | "Animal Viewing" 4 | viewer: Animal! 5 | } 6 | 7 | "Animal" 8 | type Animal { 9 | "name" 10 | name: String! 11 | 12 | "type" 13 | types: [AnimalTypes] 14 | } 15 | 16 | "Animal type enum" 17 | enum AnimalTypes { 18 | "meow" 19 | CAT_ENUM 20 | 21 | "woof" 22 | DOG_ENUM 23 | } 24 | -------------------------------------------------------------------------------- /test/strip_ansi.js: -------------------------------------------------------------------------------- 1 | const pattern = [ 2 | '[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\\u0007)', 3 | '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))', 4 | ].join('|'); 5 | 6 | const stripAnsiRegexp = new RegExp(pattern, 'g'); 7 | 8 | export function stripAnsi(input) { 9 | return input.replace(stripAnsiRegexp, ''); 10 | } 11 | -------------------------------------------------------------------------------- /test/fixtures/schema.new-implements.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | Query 3 | """ 4 | type Query { 5 | node(id: ID!): Node 6 | posts: [Post!]! 7 | } 8 | 9 | """ 10 | Post 11 | """ 12 | type Post implements Node & Commentable { 13 | id: ID! 14 | comments: [String!]! 15 | } 16 | 17 | """ 18 | Node 19 | """ 20 | interface Node { 21 | id: ID! 22 | } 23 | 24 | """ 25 | Commentable 26 | """ 27 | interface Commentable { 28 | comments: [String!]! 29 | } 30 | -------------------------------------------------------------------------------- /test/fixtures/schema.old-implements.graphql: -------------------------------------------------------------------------------- 1 | """ 2 | Query 3 | """ 4 | type Query { 5 | node(id: ID!): Node 6 | posts: [Post!]! 7 | } 8 | 9 | """ 10 | Post 11 | """ 12 | type Post implements Node, Commentable { 13 | id: ID! 14 | comments: [String!]! 15 | } 16 | 17 | """ 18 | Node 19 | """ 20 | interface Node { 21 | id: ID! 22 | } 23 | 24 | """ 25 | Commentable 26 | """ 27 | interface Commentable { 28 | comments: [String!]! 29 | } 30 | -------------------------------------------------------------------------------- /test/config/js_file/graphql-schema-linter.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rules: ['enum-values-sorted-alphabetically', 'enum-name-cannot-contain-enum'], 3 | rulesOptions: { 4 | 'enum-values-sorted-alphabetically': { sortOrder: 'lexicographical' }, 5 | }, 6 | ignore: { 7 | 'fields-have-descriptions': [ 8 | 'Obvious', 9 | 'Query.obvious', 10 | 'Query.something.obvious', 11 | ], 12 | }, 13 | customRulePaths: [`${__dirname}/../../fixtures/custom_rules/*.js`], 14 | }; 15 | -------------------------------------------------------------------------------- /test/fixtures/custom_rules/type_name_cannot_contain_type.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../../../src/validation_error'; 2 | 3 | export function TypeNameCannotContainType(context) { 4 | return { 5 | ObjectTypeDefinition(node) { 6 | const typeName = node.name.value; 7 | 8 | if (typeName.toUpperCase().indexOf('TYPE') >= 0) { 9 | context.reportError( 10 | new ValidationError( 11 | 'type-names-cannot-contain-type', 12 | `The object type \`${typeName}\` cannot include the word 'type'.`, 13 | [node.name] 14 | ) 15 | ); 16 | } 17 | }, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/util/expandPaths.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { sync as globSync, hasMagic as globHasMagic } from 'glob'; 3 | 4 | export default function expandPaths(pathOrPattern) { 5 | return ( 6 | pathOrPattern 7 | .map((path) => { 8 | if (globHasMagic(path)) { 9 | return globSync(path); 10 | } else { 11 | return path; 12 | } 13 | }) 14 | .reduce((a, b) => { 15 | return a.concat(b); 16 | }, []) 17 | // Resolve paths to absolute paths so that including the same file 18 | // multiple times is not treated as different files 19 | .map((p) => path.resolve(p)) 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/util/listIsAlphabetical.js: -------------------------------------------------------------------------------- 1 | import arraysEqual from './arraysEqual'; 2 | 3 | /** 4 | * @summary Returns `true` if the list is in alphabetical order, 5 | * or an alphabetized list if not 6 | * @param {String[]} list Array of strings 7 | * @return {Object} { isSorted: Bool, sortedList: String[] } 8 | */ 9 | export default function listIsAlphabetical(list, sortOrder = 'alphabetical') { 10 | let sortFn; 11 | if (sortOrder === 'lexicographical') { 12 | sortFn = (a, b) => a.localeCompare(b); 13 | } 14 | 15 | const sortedList = list.slice().sort(sortFn); 16 | return { 17 | isSorted: arraysEqual(list, sortedList), 18 | sortedList, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/rules/enum_values_all_caps.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | export function EnumValuesAllCaps(context) { 4 | return { 5 | EnumValueDefinition(node, key, parent, path, ancestors) { 6 | const enumValueName = node.name.value; 7 | const parentName = ancestors[ancestors.length - 1].name.value; 8 | 9 | if (enumValueName !== enumValueName.toUpperCase()) { 10 | context.reportError( 11 | new ValidationError( 12 | 'enum-values-all-caps', 13 | `The enum value \`${parentName}.${enumValueName}\` should be uppercase.`, 14 | [node] 15 | ) 16 | ); 17 | } 18 | }, 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /src/formatters/json_formatter.js: -------------------------------------------------------------------------------- 1 | export default function JSONFormatter(errorsGroupedByFile) { 2 | const files = Object.keys(errorsGroupedByFile); 3 | 4 | var errors = []; 5 | 6 | files.forEach((file) => { 7 | Array.prototype.push.apply( 8 | errors, 9 | errorsGroupedByFile[file].map((error) => { 10 | return { 11 | message: error.message, 12 | location: { 13 | line: error.locations[0].line, 14 | column: error.locations[0].column, 15 | file: file, 16 | }, 17 | rule: error.ruleName, 18 | }; 19 | }) 20 | ); 21 | }); 22 | 23 | return JSON.stringify({ 24 | errors, 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /src/cli.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { run } from './runner'; 4 | 5 | function handleUncaughtException(err) { 6 | console.error( 7 | 'It looks like you may have hit a bug in graphql-schema-linter.' 8 | ); 9 | console.error(''); 10 | console.error( 11 | 'It would be super helpful if you could report this here: https://github.com/cjoudrey/graphql-schema-linter/issues/new' 12 | ); 13 | console.error(''); 14 | console.error(err.stack); 15 | process.exit(3); 16 | } 17 | 18 | process.on('uncaughtException', handleUncaughtException); 19 | 20 | run(process.stdout, process.stdin, process.stderr, process.argv) 21 | .then(exitCode => process.exit(exitCode)) 22 | .catch(err => handleUncaughtException(err)); 23 | -------------------------------------------------------------------------------- /src/rules/fields_are_camel_cased.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | const camelCaseTest = RegExp('^[a-z][a-zA-Z0-9]*$'); 4 | 5 | export function FieldsAreCamelCased(context) { 6 | return { 7 | FieldDefinition(node, key, parent, path, ancestors) { 8 | const fieldName = node.name.value; 9 | if (!camelCaseTest.test(fieldName)) { 10 | const parentName = ancestors[ancestors.length - 1].name.value; 11 | context.reportError( 12 | new ValidationError( 13 | 'fields-are-camel-cased', 14 | `The field \`${parentName}.${fieldName}\` is not camel cased.`, 15 | [node] 16 | ) 17 | ); 18 | } 19 | }, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/formatters/compact_formatter.js: -------------------------------------------------------------------------------- 1 | // Text format for easy machine parsing. 2 | import columnify from 'columnify'; 3 | 4 | export default function CompactFormatter(errorsGroupedByFile) { 5 | const files = Object.keys(errorsGroupedByFile); 6 | 7 | const errorsText = files 8 | .map((file) => { 9 | return generateErrorsForFile(file, errorsGroupedByFile[file]); 10 | }) 11 | .join('\n'); 12 | return errorsText + '\n'; 13 | } 14 | 15 | function generateErrorsForFile(file, errors) { 16 | const formattedErrors = errors.map((error) => { 17 | const location = error.locations[0]; 18 | return `${file}:${location.line}:${location.column} ${error.message} (${error.ruleName})`; 19 | }); 20 | 21 | return formattedErrors.join('\n'); 22 | } 23 | -------------------------------------------------------------------------------- /test/fixtures/custom_rules/enum_name_cannot_contain_enum.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../../../src/validation_error'; 2 | 3 | export function EnumNameCannotContainEnum(context) { 4 | return { 5 | EnumValueDefinition(node, key, parent, path, ancestors) { 6 | const enumValueName = node.name.value; 7 | const parentName = ancestors[ancestors.length - 1].name.value; 8 | 9 | if (enumValueName.toUpperCase().indexOf('ENUM') >= 0) { 10 | context.reportError( 11 | new ValidationError( 12 | 'enum-name-cannot-contain-enum', 13 | `The enum value \`${parentName}.${ 14 | enumValueName 15 | }\` cannot include the word 'enum'.`, 16 | [node] 17 | ) 18 | ); 19 | } 20 | }, 21 | }; 22 | } 23 | -------------------------------------------------------------------------------- /src/rules/fields_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { getDescription } from '../util/getDescription'; 2 | import { ValidationError } from '../validation_error'; 3 | 4 | export function FieldsHaveDescriptions(configuration, context) { 5 | return { 6 | FieldDefinition(node, key, parent, path, ancestors) { 7 | if ( 8 | getDescription(node, { 9 | commentDescriptions: configuration.getCommentDescriptions(), 10 | }) 11 | ) { 12 | return; 13 | } 14 | 15 | const fieldName = node.name.value; 16 | const parentName = ancestors[ancestors.length - 1].name.value; 17 | 18 | context.reportError( 19 | new ValidationError( 20 | 'fields-have-descriptions', 21 | `The field \`${parentName}.${fieldName}\` is missing a description.`, 22 | [node] 23 | ) 24 | ); 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/rules/input_object_values_are_camel_cased.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | const camelCaseTest = RegExp('^[a-z][a-zA-Z0-9]*$'); 4 | 5 | export function InputObjectValuesAreCamelCased(context) { 6 | return { 7 | InputValueDefinition(node, key, parent, path, ancestors) { 8 | const inputValueName = node.name.value; 9 | const parentNode = ancestors[ancestors.length - 1]; 10 | 11 | const fieldName = node.name.value; 12 | if (!camelCaseTest.test(fieldName)) { 13 | const inputObjectName = parentNode.name.value; 14 | context.reportError( 15 | new ValidationError( 16 | 'input-object-values-are-camel-cased', 17 | `The input value \`${inputObjectName}.${inputValueName}\` is not camel cased.`, 18 | [node] 19 | ) 20 | ); 21 | } 22 | }, 23 | }; 24 | } 25 | -------------------------------------------------------------------------------- /src/rules/enum_values_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { getDescription } from '../util/getDescription'; 2 | import { ValidationError } from '../validation_error'; 3 | 4 | export function EnumValuesHaveDescriptions(configuration, context) { 5 | return { 6 | EnumValueDefinition(node, key, parent, path, ancestors) { 7 | if ( 8 | getDescription(node, { 9 | commentDescriptions: configuration.getCommentDescriptions(), 10 | }) 11 | ) { 12 | return; 13 | } 14 | 15 | const enumValue = node.name.value; 16 | const parentName = ancestors[ancestors.length - 1].name.value; 17 | 18 | context.reportError( 19 | new ValidationError( 20 | 'enum-values-have-descriptions', 21 | `The enum value \`${parentName}.${enumValue}\` is missing a description.`, 22 | [node] 23 | ) 24 | ); 25 | }, 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/rules/arguments_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { getDescription } from '../util/getDescription'; 2 | import { ValidationError } from '../validation_error'; 3 | 4 | export function ArgumentsHaveDescriptions(configuration, context) { 5 | return { 6 | FieldDefinition(node) { 7 | const fieldName = node.name.value; 8 | 9 | for (const arg of node.arguments || []) { 10 | const description = getDescription(arg, { 11 | commentDescriptions: configuration.getCommentDescriptions(), 12 | }); 13 | 14 | if (typeof description !== 'string' || description.length === 0) { 15 | const argName = arg.name.value; 16 | 17 | context.reportError( 18 | new ValidationError( 19 | 'arguments-have-descriptions', 20 | `The \`${argName}\` argument of \`${fieldName}\` is missing a description.`, 21 | [arg] 22 | ) 23 | ); 24 | } 25 | } 26 | }, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/rules/types_are_capitalized.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | export function TypesAreCapitalized(context) { 4 | return { 5 | ObjectTypeDefinition(node) { 6 | const typeName = node.name.value; 7 | if (typeName[0] == typeName[0].toLowerCase()) { 8 | context.reportError( 9 | new ValidationError( 10 | 'types-are-capitalized', 11 | `The object type \`${typeName}\` should start with a capital letter.`, 12 | [node.name] 13 | ) 14 | ); 15 | } 16 | }, 17 | 18 | InterfaceTypeDefinition(node) { 19 | const typeName = node.name.value; 20 | if (typeName[0] == typeName[0].toLowerCase()) { 21 | context.reportError( 22 | new ValidationError( 23 | 'types-are-capitalized', 24 | `The interface type \`${typeName}\` should start with a capital letter.`, 25 | [node.name] 26 | ) 27 | ); 28 | } 29 | }, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/rules/type_fields_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | import listIsAlphabetical from '../util/listIsAlphabetical'; 3 | 4 | export function TypeFieldsSortedAlphabetically(configuration, context) { 5 | const ruleKey = 'type-fields-sorted-alphabetically'; 6 | return { 7 | ObjectTypeDefinition(node) { 8 | const fieldList = (node.fields || []).map((field) => field.name.value); 9 | 10 | const { sortOrder = 'alphabetical' } = 11 | configuration.getRulesOptions()[ruleKey] || {}; 12 | const { isSorted, sortedList } = listIsAlphabetical(fieldList, sortOrder); 13 | 14 | if (!isSorted) { 15 | context.reportError( 16 | new ValidationError( 17 | ruleKey, 18 | `The fields of object type \`${node.name.value}\` should be sorted in ${sortOrder} order. ` + 19 | `Expected sorting: ${sortedList.join(', ')}`, 20 | [node] 21 | ) 22 | ); 23 | } 24 | }, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/rules/input_object_fields_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | import listIsAlphabetical from '../util/listIsAlphabetical'; 3 | 4 | export function InputObjectFieldsSortedAlphabetically(configuration, context) { 5 | const ruleKey = 'input-object-fields-sorted-alphabetically'; 6 | return { 7 | InputObjectTypeDefinition(node) { 8 | const fieldList = (node.fields || []).map((field) => field.name.value); 9 | 10 | const { sortOrder = 'alphabetical' } = 11 | configuration.getRulesOptions()[ruleKey] || {}; 12 | const { isSorted, sortedList } = listIsAlphabetical(fieldList, sortOrder); 13 | 14 | if (!isSorted) { 15 | context.reportError( 16 | new ValidationError( 17 | ruleKey, 18 | `The fields of input type \`${node.name.value}\` should be sorted in ${sortOrder} order. ` + 19 | `Expected sorting: ${sortedList.join(', ')}`, 20 | [node] 21 | ) 22 | ); 23 | } 24 | }, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /test/formatters/compact_formatter.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import CompactFormatter from '../../src/formatters/compact_formatter.js'; 3 | 4 | describe('CompactFormatter', () => { 5 | it('returns a single newline when there are no errors', () => { 6 | const expected = '\n'; 7 | assert.equal(CompactFormatter({}), expected); 8 | }); 9 | 10 | it('returns a single block of text for all errors otherwise', () => { 11 | const errors = { 12 | file1: [ 13 | { 14 | message: 'error.', 15 | locations: [{ line: 1, column: 1 }], 16 | ruleName: 'a-rule', 17 | }, 18 | ], 19 | file2: [ 20 | { 21 | message: 'another error.', 22 | locations: [{ line: 1, column: 1 }], 23 | ruleName: 'a-rule', 24 | }, 25 | ], 26 | }; 27 | 28 | const expected = 29 | '' + 30 | 'file1:1:1 error. (a-rule)\n' + 31 | 'file2:1:1 another error. (a-rule)\n'; 32 | 33 | assert.equal(CompactFormatter(errors), expected); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /test/rules/enum_values_all_caps.js: -------------------------------------------------------------------------------- 1 | import { EnumValuesAllCaps } from '../../src/rules/enum_values_all_caps'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('EnumValuesAllCaps rule', () => { 5 | it('catches enums that are lower case', () => { 6 | expectFailsRule( 7 | EnumValuesAllCaps, 8 | ` 9 | enum Stage { 10 | aaa 11 | bbb_bbb 12 | } 13 | `, 14 | [ 15 | { 16 | message: 'The enum value `Stage.aaa` should be uppercase.', 17 | locations: [{ line: 3, column: 9 }], 18 | }, 19 | { 20 | message: 'The enum value `Stage.bbb_bbb` should be uppercase.', 21 | locations: [{ line: 4, column: 9 }], 22 | }, 23 | ] 24 | ); 25 | }); 26 | 27 | it('allows enums that are uppercase, numbers allowed ', () => { 28 | expectPassesRule( 29 | EnumValuesAllCaps, 30 | ` 31 | enum Stage { 32 | FOO 33 | FOO_BAR 34 | FOO_BAR_1 35 | } 36 | ` 37 | ); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /test/rules/types_are_capitalized.js: -------------------------------------------------------------------------------- 1 | import { TypesAreCapitalized } from '../../src/rules/types_are_capitalized'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('TypesAreCapitalized rule', () => { 5 | it('catches object types that are not capitalized', () => { 6 | expectFailsRule( 7 | TypesAreCapitalized, 8 | ` 9 | type a { 10 | a: String 11 | } 12 | `, 13 | [ 14 | { 15 | message: 'The object type `a` should start with a capital letter.', 16 | locations: [{ line: 2, column: 12 }], 17 | }, 18 | ] 19 | ); 20 | }); 21 | 22 | it('catches interface types that are not capitalized', () => { 23 | expectFailsRule( 24 | TypesAreCapitalized, 25 | ` 26 | interface a { 27 | a: String 28 | } 29 | `, 30 | [ 31 | { 32 | message: 'The interface type `a` should start with a capital letter.', 33 | locations: [{ line: 2, column: 17 }], 34 | }, 35 | ] 36 | ); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/rules/interface_fields_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | import listIsAlphabetical from '../util/listIsAlphabetical'; 3 | 4 | export function InterfaceFieldsSortedAlphabetically(configuration, context) { 5 | return { 6 | InterfaceTypeDefinition(node) { 7 | const ruleKey = 'interface-fields-sorted-alphabetically'; 8 | const fieldList = (node.fields || []).map((field) => field.name.value); 9 | 10 | const { sortOrder = 'alphabetical' } = 11 | configuration.getRulesOptions()[ruleKey] || {}; 12 | const { isSorted, sortedList } = listIsAlphabetical(fieldList, sortOrder); 13 | 14 | if (!isSorted) { 15 | context.reportError( 16 | new ValidationError( 17 | 'interface-fields-sorted-alphabetically', 18 | `The fields of interface type \`${node.name.value}\` should be sorted in ${sortOrder} order. ` + 19 | `Expected sorting: ${sortedList.join(', ')}`, 20 | [node] 21 | ) 22 | ); 23 | } 24 | }, 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /src/rules/enum_values_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | import listIsAlphabetical from '../util/listIsAlphabetical'; 3 | 4 | export function EnumValuesSortedAlphabetically(configuration, context) { 5 | const ruleKey = 'enum-values-sorted-alphabetically'; 6 | return { 7 | EnumTypeDefinition(node, key, parent, path, ancestors) { 8 | const enumValues = node.values.map((val) => { 9 | return val.name.value; 10 | }); 11 | 12 | const { sortOrder = 'alphabetical' } = 13 | configuration.getRulesOptions()[ruleKey] || {}; 14 | const { isSorted, sortedList } = listIsAlphabetical( 15 | enumValues, 16 | sortOrder 17 | ); 18 | 19 | if (!isSorted) { 20 | context.reportError( 21 | new ValidationError( 22 | ruleKey, 23 | `The enum \`${node.name.value}\` should be sorted in ${sortOrder} order. ` + 24 | `Expected sorting: ${sortedList.join(', ')}`, 25 | [node] 26 | ) 27 | ); 28 | } 29 | }, 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/rules/input_object_values_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { getDescription } from '../util/getDescription'; 2 | import { ValidationError } from '../validation_error'; 3 | 4 | export function InputObjectValuesHaveDescriptions(configuration, context) { 5 | return { 6 | InputValueDefinition(node, key, parent, path, ancestors) { 7 | if ( 8 | getDescription(node, { 9 | commentDescriptions: configuration.getCommentDescriptions(), 10 | }) 11 | ) { 12 | return; 13 | } 14 | 15 | const inputValueName = node.name.value; 16 | const parentNode = ancestors[ancestors.length - 1]; 17 | 18 | if (parentNode.kind != 'InputObjectTypeDefinition') { 19 | return; 20 | } 21 | 22 | const inputObjectName = parentNode.name.value; 23 | 24 | context.reportError( 25 | new ValidationError( 26 | 'input-object-values-have-descriptions', 27 | `The input value \`${inputObjectName}.${inputValueName}\` is missing a description.`, 28 | [node] 29 | ) 30 | ); 31 | }, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Christian Joudrey 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/find_schema_nodes.js: -------------------------------------------------------------------------------- 1 | import { visit } from 'graphql'; 2 | 3 | /* 4 | scopes: (required) string array 5 | schema: (required) GraphQLSchema 6 | 7 | A scope could be a `Type`, `Type.field`, `Type.field.argument` or `Enum.VALUE`. 8 | */ 9 | export function findSchemaNodes(scopes, schema) { 10 | const result = new Set(); 11 | const tracer = { 12 | enter: (node) => { 13 | result.add(node); 14 | }, 15 | leave: () => {}, 16 | }; 17 | for (const scope of scopes) { 18 | const node = findScopeNode(scope, schema); 19 | node && visit(node, tracer); 20 | } 21 | return result; 22 | } 23 | 24 | function findScopeNode(scope, schema) { 25 | const [typeName, fieldName, argumentName] = scope.split('.'); 26 | const type = schema.getType(typeName); 27 | let astNode = type?.astNode; 28 | if (fieldName === undefined) { 29 | return astNode; 30 | } 31 | 32 | const field = 33 | astNode?.kind === 'EnumTypeDefinition' 34 | ? type?.getValue(fieldName) 35 | : type?.getFields()[fieldName]; 36 | astNode = field?.astNode; 37 | if (argumentName === undefined) { 38 | return astNode; 39 | } 40 | 41 | const argument = field?.args.find((arg) => arg.name === argumentName); 42 | astNode = argument?.astNode; 43 | return astNode; 44 | } 45 | -------------------------------------------------------------------------------- /test/rules/input_object_values_are_camel_cased.js: -------------------------------------------------------------------------------- 1 | import { InputObjectValuesAreCamelCased } from '../../src/rules/input_object_values_are_camel_cased'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('InputObjectValuesAreCamelCased rule', () => { 5 | it('catches input object type values that are not camel cased', () => { 6 | expectFailsRule( 7 | InputObjectValuesAreCamelCased, 8 | ` 9 | input User { 10 | user_name: String 11 | 12 | userID: String 13 | withDescription: String 14 | } 15 | `, 16 | [ 17 | { 18 | message: 'The input value `User.user_name` is not camel cased.', 19 | locations: [{ line: 3, column: 9 }], 20 | }, 21 | ] 22 | ); 23 | }); 24 | 25 | it('catches arguments that are not camel cased', () => { 26 | expectFailsRule( 27 | InputObjectValuesAreCamelCased, 28 | ` 29 | type A { 30 | hello(argument_without_description: String): String 31 | } 32 | `, 33 | [ 34 | { 35 | message: 36 | 'The input value `hello.argument_without_description` is not camel cased.', 37 | locations: [{ line: 3, column: 15 }], 38 | }, 39 | ] 40 | ); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/options.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import cosmiconfig from 'cosmiconfig'; 4 | 5 | export function loadOptionsFromConfigDir(configDirectory) { 6 | const searchPath = configDirectory || './'; 7 | 8 | const cosmic = cosmiconfig('graphql-schema-linter', { 9 | cache: false, 10 | }).searchSync(searchPath); 11 | 12 | if (cosmic) { 13 | let schemaPaths = []; 14 | let customRulePaths = []; 15 | 16 | // If schemaPaths come from cosmic, we resolve the given paths relative to the searchPath. 17 | if (cosmic.config.schemaPaths) { 18 | schemaPaths = cosmic.config.schemaPaths.map((schemaPath) => 19 | path.resolve(searchPath, schemaPath) 20 | ); 21 | } 22 | 23 | // If customRulePaths come from cosmic, we resolve the given paths relative to the searchPath. 24 | if (cosmic.config.customRulePaths) { 25 | customRulePaths = cosmic.config.customRulePaths.map((schemaPath) => 26 | path.resolve(searchPath, schemaPath) 27 | ); 28 | } 29 | 30 | return { 31 | rules: cosmic.config.rules, 32 | rulesOptions: cosmic.config.rulesOptions || {}, 33 | ignore: cosmic.config.ignore || {}, 34 | customRulePaths: customRulePaths || [], 35 | schemaPaths: schemaPaths, 36 | }; 37 | } else { 38 | return {}; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/inline_configuration.js: -------------------------------------------------------------------------------- 1 | export function extractInlineConfigs(ast) { 2 | var configurations = []; 3 | 4 | var token = ast.loc.startToken; 5 | 6 | while (token) { 7 | if (token.kind == 'Comment' && token.value.startsWith(' lint-')) { 8 | const previousToken = token.prev; 9 | const nextToken = token.next; 10 | 11 | previousToken.next = nextToken; 12 | nextToken.prev = previousToken; 13 | 14 | configurations.push(parseInlineComment(token)); 15 | } 16 | 17 | token = token.next; 18 | } 19 | 20 | return configurations; 21 | } 22 | 23 | function parseInlineComment(token) { 24 | const matches = /^\s{0,}(lint-[^\s]+)(\s(.*))?$/g.exec(token.value); 25 | 26 | switch (matches[1]) { 27 | case 'lint-enable': 28 | return { 29 | command: 'enable', 30 | rules: parseRulesArg(matches[3]), 31 | line: token.line, 32 | }; 33 | 34 | case 'lint-disable': 35 | return { 36 | command: 'disable', 37 | rules: parseRulesArg(matches[3]), 38 | line: token.line, 39 | }; 40 | 41 | case 'lint-disable-line': 42 | return { 43 | command: 'disable-line', 44 | rules: parseRulesArg(matches[3]), 45 | line: token.line, 46 | }; 47 | } 48 | } 49 | 50 | function parseRulesArg(value) { 51 | return value.split(/\,\s+/); 52 | } 53 | -------------------------------------------------------------------------------- /test/rules/fields_are_camel_cased.js: -------------------------------------------------------------------------------- 1 | import { FieldsAreCamelCased } from '../../src/rules/fields_are_camel_cased'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('FieldsAreCamelCased rule', () => { 5 | it('catches fields that have are not camelcased', () => { 6 | expectFailsRule( 7 | FieldsAreCamelCased, 8 | ` 9 | type A { 10 | # Invalid 11 | invalid_name: String 12 | 13 | # Valid 14 | thisIsValid: String 15 | 16 | # Valid 17 | thisIDIsValid: String 18 | 19 | # Invalid 20 | ThisIsInvalid: String 21 | } 22 | 23 | interface Something { 24 | # Invalid 25 | invalid_name: String 26 | 27 | # Valid 28 | thisIsValid: String 29 | 30 | # Valid 31 | thisIDIsValid: String 32 | } 33 | `, 34 | [ 35 | { 36 | message: 'The field `A.invalid_name` is not camel cased.', 37 | locations: [{ line: 4, column: 9 }], 38 | }, 39 | { 40 | message: 'The field `A.ThisIsInvalid` is not camel cased.', 41 | locations: [{ line: 13, column: 9 }], 42 | }, 43 | { 44 | message: 'The field `Something.invalid_name` is not camel cased.', 45 | locations: [{ line: 18, column: 9 }], 46 | }, 47 | ] 48 | ); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/rules/enum_values_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { version } from 'graphql'; 2 | import { EnumValuesHaveDescriptions } from '../../src/rules/enum_values_have_descriptions'; 3 | import { 4 | expectFailsRule, 5 | expectPassesRuleWithConfiguration, 6 | } from '../assertions'; 7 | 8 | const itPotentially = version.startsWith('15.') ? it : it.skip; 9 | 10 | describe('EnumValuesHaveDescriptions rule', () => { 11 | it('catches enum values that have no description', () => { 12 | expectFailsRule( 13 | EnumValuesHaveDescriptions, 14 | ` 15 | enum Status { 16 | DRAFT 17 | 18 | "Hidden" 19 | HIDDEN 20 | 21 | PUBLISHED 22 | } 23 | `, 24 | [ 25 | { 26 | message: 'The enum value `Status.DRAFT` is missing a description.', 27 | locations: [{ line: 3, column: 9 }], 28 | }, 29 | { 30 | message: 31 | 'The enum value `Status.PUBLISHED` is missing a description.', 32 | locations: [{ line: 8, column: 9 }], 33 | }, 34 | ] 35 | ); 36 | }); 37 | 38 | itPotentially( 39 | 'get descriptions correctly with commentDescriptions option', 40 | () => { 41 | expectPassesRuleWithConfiguration( 42 | EnumValuesHaveDescriptions, 43 | ` 44 | enum Status { 45 | # Hidden 46 | HIDDEN 47 | } 48 | `, 49 | { commentDescriptions: true } 50 | ); 51 | } 52 | ); 53 | }); 54 | -------------------------------------------------------------------------------- /test/rules/fields_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { version } from 'graphql'; 2 | import { FieldsHaveDescriptions } from '../../src/rules/fields_have_descriptions'; 3 | import { 4 | expectFailsRule, 5 | expectPassesRuleWithConfiguration, 6 | } from '../assertions'; 7 | 8 | const itPotentially = version.startsWith('15.') ? it : it.skip; 9 | 10 | describe('FieldsHaveDescriptions rule', () => { 11 | it('catches fields that have no description', () => { 12 | expectFailsRule( 13 | FieldsHaveDescriptions, 14 | ` 15 | type A { 16 | withoutDescription: String 17 | withoutDescriptionAgain: String! 18 | 19 | "Description" 20 | withDescription: String 21 | } 22 | `, 23 | [ 24 | { 25 | message: 'The field `A.withoutDescription` is missing a description.', 26 | locations: [{ line: 3, column: 9 }], 27 | }, 28 | { 29 | message: 30 | 'The field `A.withoutDescriptionAgain` is missing a description.', 31 | locations: [{ line: 4, column: 9 }], 32 | }, 33 | ] 34 | ); 35 | }); 36 | 37 | itPotentially( 38 | 'gets descriptions correctly with commentDescriptions option', 39 | () => { 40 | expectPassesRuleWithConfiguration( 41 | FieldsHaveDescriptions, 42 | ` 43 | type A { 44 | "Description" 45 | withDescription: String 46 | } 47 | `, 48 | { commentDescriptions: true } 49 | ); 50 | } 51 | ); 52 | }); 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | We love pull requests from everyone. By participating in this project, you agree to abide by the thoughtbot [code of conduct](https://github.com/cjoudrey/graphql-schema-linter/blob/master/CODE_OF_CONDUCT.md). 6 | 7 | **Working on your first Pull Request?** You can learn how from this free series 8 | [How to Contribute to an Open Source Project on GitHub](https://egghead.io/courses/how-to-contribute-to-an-open-source-project-on-github) 9 | 10 | ## Project setup 11 | 12 | 1. Fork and clone the repository. 13 | 2. Run `npm install` to install dependencies 14 | 15 | > Tip: Keep your `master` branch pointing at the original repository and make 16 | > pull requests from branches on your fork. 17 | > To do this, run: 18 | > 19 | > ``` 20 | > git remote add upstream https://github.com/cjoudrey/graphql-schema-linter.git 21 | > git fetch upstream 22 | > git branch --set-upstream-to=upstream/master master 23 | > ``` 24 | > 25 | > This will add the original repository as a "remote" called "upstream", 26 | > then fetch the git information from that remote, then set your local `master` 27 | > branch to use the upstream master branch whenever you run `git pull`. 28 | > Then you can make all of your pull request branches based on this `master` 29 | > branch. Whenever you want to update your version of `master`, do a regular 30 | > `git pull`. 31 | 32 | ## Committing and Pushing changes 33 | 34 | Please make sure to run the tests `npm run test` before you commit your changes. 35 | 36 | -------------------------------------------------------------------------------- /src/rules/descriptions_are_capitalized.js: -------------------------------------------------------------------------------- 1 | import { getDescription } from '../util/getDescription'; 2 | import { ValidationError } from '../validation_error'; 3 | 4 | export function DescriptionsAreCapitalized(configuration, context) { 5 | return { 6 | FieldDefinition(node, key, parent, path, ancestors) { 7 | const description = getDescription(node, { 8 | commentDescriptions: configuration.getCommentDescriptions(), 9 | }); 10 | 11 | // Rule should pass if there's an empty/missing string description. If empty 12 | // strings aren't wanted, the `*_have_descriptions` rules can be used. 13 | if (typeof description !== 'string' || description.length === 0) return; 14 | 15 | // It's possible there could be some markdown characters that do not 16 | // pass this test. If we discover some examples of this, we can improve. 17 | // More likely this test will actually miss some issues due to markdown 18 | // characters, for example if the first word is formatted. 19 | const firstCharacter = description[0]; 20 | if (firstCharacter === firstCharacter.toUpperCase()) return; 21 | 22 | const fieldName = node.name.value; 23 | const parentName = ancestors[ancestors.length - 1].name.value; 24 | 25 | context.reportError( 26 | new ValidationError( 27 | 'descriptions-are-capitalized', 28 | `The description for field \`${parentName}.${fieldName}\` should be capitalized.`, 29 | [node] 30 | ) 31 | ); 32 | }, 33 | }; 34 | } 35 | -------------------------------------------------------------------------------- /src/source_map.js: -------------------------------------------------------------------------------- 1 | export class SourceMap { 2 | constructor(sourceFiles) { 3 | this.sourceFiles = sourceFiles; 4 | this.offsets = this._computeOffsets(); 5 | } 6 | 7 | _computeOffsets() { 8 | var currentOffset = 1; 9 | 10 | const paths = Object.keys(this.sourceFiles); 11 | 12 | return paths.reduce((offsets, path) => { 13 | const currentSegment = this.sourceFiles[path]; 14 | const currentSegmentLines = currentSegment.match(/\r?\n/g); 15 | const amountLines = currentSegmentLines ? currentSegmentLines.length : 0; 16 | 17 | const startLine = currentOffset; 18 | const endLine = currentOffset + amountLines; 19 | 20 | currentOffset = currentOffset + amountLines + 1; 21 | 22 | offsets[path] = { 23 | startLine, 24 | endLine, 25 | filename: path, 26 | }; 27 | 28 | return offsets; 29 | }, {}); 30 | } 31 | 32 | getCombinedSource() { 33 | return getObjectValues(this.sourceFiles).join('\n'); 34 | } 35 | 36 | getOriginalPathForLine(lineNumber) { 37 | const offsets = getObjectValues(this.offsets); 38 | 39 | for (var i = 0; i < offsets.length; i++) { 40 | if ( 41 | offsets[i].startLine <= lineNumber && 42 | lineNumber <= offsets[i].endLine 43 | ) { 44 | return offsets[i].filename; 45 | } 46 | } 47 | } 48 | 49 | getOffsetForPath(path) { 50 | return this.offsets[path]; 51 | } 52 | } 53 | 54 | function getObjectValues(arr) { 55 | return Object.keys(arr).map(function(key) { 56 | return arr[key]; 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /src/formatters/text_formatter.js: -------------------------------------------------------------------------------- 1 | import columnify from 'columnify'; 2 | import figures from '../figures'; 3 | import chalk from 'chalk'; 4 | 5 | export default function TextFormatter(errorsGroupedByFile) { 6 | const files = Object.keys(errorsGroupedByFile); 7 | 8 | const errorsText = files 9 | .map((file) => { 10 | return generateErrorsForFile(file, errorsGroupedByFile[file]); 11 | }) 12 | .join('\n\n'); 13 | 14 | const summary = generateSummary(errorsGroupedByFile); 15 | 16 | return errorsText + '\n\n' + summary + '\n'; 17 | } 18 | 19 | function generateErrorsForFile(file, errors) { 20 | const formattedErrors = errors.map((error) => { 21 | const location = error.locations[0]; 22 | 23 | return { 24 | location: chalk.dim(`${location.line}:${location.column}`), 25 | message: error.message, 26 | rule: chalk.dim(` ${error.ruleName}`), 27 | }; 28 | }); 29 | 30 | const errorsText = columnify(formattedErrors, { 31 | showHeaders: false, 32 | }); 33 | 34 | return chalk.underline(file) + '\n' + errorsText; 35 | } 36 | 37 | function generateSummary(errorsGroupedByFile) { 38 | const files = Object.keys(errorsGroupedByFile); 39 | 40 | const errorsCount = files.reduce((sum, file) => { 41 | return sum + errorsGroupedByFile[file].length; 42 | }, 0); 43 | 44 | if (errorsCount == 0) { 45 | return chalk.green(`${figures.tick} 0 errors detected\n`); 46 | } 47 | 48 | const summary = chalk.red( 49 | `${figures.cross} ${errorsCount} error` + 50 | (errorsCount > 1 ? 's' : '') + 51 | ' detected' 52 | ); 53 | 54 | return summary; 55 | } 56 | -------------------------------------------------------------------------------- /src/rules/types_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { getDescription } from '../util/getDescription'; 2 | import { ValidationError } from '../validation_error'; 3 | 4 | function validateTypeHasDescription(configuration, context, node, typeKind) { 5 | if ( 6 | getDescription(node, { 7 | commentDescriptions: configuration.getCommentDescriptions(), 8 | }) 9 | ) { 10 | return; 11 | } 12 | 13 | const interfaceTypeName = node.name.value; 14 | 15 | context.reportError( 16 | new ValidationError( 17 | 'types-have-descriptions', 18 | `The ${typeKind} type \`${interfaceTypeName}\` is missing a description.`, 19 | [node] 20 | ) 21 | ); 22 | } 23 | 24 | export function TypesHaveDescriptions(configuration, context) { 25 | return { 26 | TypeExtensionDefinition(node) { 27 | return false; 28 | }, 29 | 30 | ScalarTypeDefinition(node) { 31 | validateTypeHasDescription(configuration, context, node, 'scalar'); 32 | }, 33 | 34 | ObjectTypeDefinition(node) { 35 | validateTypeHasDescription(configuration, context, node, 'object'); 36 | }, 37 | 38 | InterfaceTypeDefinition(node) { 39 | validateTypeHasDescription(configuration, context, node, 'interface'); 40 | }, 41 | 42 | UnionTypeDefinition(node) { 43 | validateTypeHasDescription(configuration, context, node, 'union'); 44 | }, 45 | 46 | EnumTypeDefinition(node) { 47 | validateTypeHasDescription(configuration, context, node, 'enum'); 48 | }, 49 | 50 | InputObjectTypeDefinition(node) { 51 | validateTypeHasDescription(configuration, context, node, 'input object'); 52 | }, 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | // This file cannot be written with ECMAScript 2015 because it has to load 2 | // the Babel require hook to enable ECMAScript 2015 features! 3 | require('@babel/register'); 4 | 5 | // The tests, however, can and should be written with ECMAScript 2015. 6 | require('./configuration'); 7 | require('./runner'); 8 | require('./schema'); 9 | require('./source_map'); 10 | require('./validator'); 11 | require('./inline_configuration'); 12 | require('./find_schema_nodes'); 13 | require('./rules/arguments_have_descriptions'); 14 | require('./rules/defined_types_are_used'); 15 | require('./rules/deprecations_have_a_reason'); 16 | require('./rules/descriptions_are_capitalized'); 17 | require('./rules/enum_values_all_caps'); 18 | require('./rules/enum_values_have_descriptions'); 19 | require('./rules/enum_values_sorted_alphabetically'); 20 | require('./rules/fields_are_camel_cased.js'); 21 | require('./rules/fields_have_descriptions'); 22 | require('./rules/input_object_fields_sorted_alphabetically'); 23 | require('./rules/input_object_values_are_camel_cased'); 24 | require('./rules/input_object_values_have_descriptions'); 25 | require('./rules/interface_fields_sorted_alphabetically'); 26 | require('./rules/relay_connection_arguments_spec'); 27 | require('./rules/relay_connection_types_spec'); 28 | require('./rules/relay_page_info_spec'); 29 | require('./rules/type_fields_sorted_alphabetically'); 30 | require('./rules/types_are_capitalized'); 31 | require('./rules/types_have_descriptions'); 32 | require('./formatters/json_formatter'); 33 | require('./formatters/text_formatter'); 34 | require('./formatters/compact_formatter.js'); 35 | require('./config/rc_file/test'); 36 | require('./config/package_json/test'); 37 | require('./config/js_file/test'); 38 | -------------------------------------------------------------------------------- /test/formatters/json_formatter.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import JSONFormatter from '../../src/formatters/json_formatter'; 3 | 4 | describe('JSONFormatter', () => { 5 | it('returns an array of errors in JSON format', () => { 6 | const errors = { 7 | 'schema/query.graphql': [ 8 | { 9 | message: 10 | 'The field `Query.users` is deprecated but has no deprecation reason.', 11 | locations: [{ line: 4, column: 20 }], 12 | ruleName: 'deprecations-have-a-reason', 13 | }, 14 | ], 15 | 16 | 'schema/user.graphql': [ 17 | { 18 | message: 'The field `User.email` is missing a description.', 19 | locations: [{ line: 3, column: 3 }], 20 | ruleName: 'fields-have-descriptions', 21 | }, 22 | ], 23 | }; 24 | 25 | assert.equal( 26 | JSONFormatter(errors), 27 | JSON.stringify({ 28 | errors: [ 29 | { 30 | message: 31 | 'The field `Query.users` is deprecated but has no deprecation reason.', 32 | location: { 33 | line: 4, 34 | column: 20, 35 | file: 'schema/query.graphql', 36 | }, 37 | rule: 'deprecations-have-a-reason', 38 | }, 39 | { 40 | message: 'The field `User.email` is missing a description.', 41 | location: { 42 | line: 3, 43 | column: 3, 44 | file: 'schema/user.graphql', 45 | }, 46 | rule: 'fields-have-descriptions', 47 | }, 48 | ], 49 | }) 50 | ); 51 | }); 52 | 53 | it('returns an empty array when there is no errors', () => { 54 | assert.equal(JSONFormatter({}), JSON.stringify({ errors: [] })); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/rules/input_object_values_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { version, parse } from 'graphql'; 3 | import { validate } from 'graphql/validation'; 4 | import { buildASTSchema } from 'graphql/utilities/buildASTSchema'; 5 | 6 | import { InputObjectValuesHaveDescriptions } from '../../src/rules/input_object_values_have_descriptions'; 7 | import { 8 | expectFailsRule, 9 | expectPassesRule, 10 | expectPassesRuleWithConfiguration, 11 | } from '../assertions'; 12 | 13 | const itPotentially = version.startsWith('15.') ? it : it.skip; 14 | 15 | describe('InputObjectValuesHaveDescriptions rule', () => { 16 | it('catches input object type values that have no description', () => { 17 | expectFailsRule( 18 | InputObjectValuesHaveDescriptions, 19 | ` 20 | input User { 21 | username: String 22 | 23 | "Description" 24 | withDescription: String 25 | } 26 | `, 27 | [ 28 | { 29 | message: 'The input value `User.username` is missing a description.', 30 | locations: [{ line: 3, column: 9 }], 31 | }, 32 | ] 33 | ); 34 | }); 35 | 36 | it('ignores arguments that have no description', () => { 37 | expectPassesRule( 38 | InputObjectValuesHaveDescriptions, 39 | ` 40 | type A { 41 | hello(argumentWithoutDescription: String): String 42 | } 43 | ` 44 | ); 45 | }); 46 | 47 | itPotentially( 48 | 'gets descriptions correctly with commentDescriptions option', 49 | () => { 50 | expectPassesRuleWithConfiguration( 51 | InputObjectValuesHaveDescriptions, 52 | ` 53 | input F { 54 | # F 55 | f: String 56 | } 57 | `, 58 | { commentDescriptions: true } 59 | ); 60 | } 61 | ); 62 | }); 63 | -------------------------------------------------------------------------------- /test/source_map.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { SourceMap } from '../src/source_map'; 3 | 4 | const sourceFiles = { 5 | 'query.graphql': `type Query { 6 | a: String! 7 | }`, 8 | 9 | 'user.graphql': `type User { 10 | username: String! 11 | email: String! 12 | }`, 13 | 14 | 'schema.graphql': 'schema { query: Query }', 15 | 'comment.graphql': 'type Comment { user: User! body: String! }', 16 | }; 17 | 18 | describe('SourceMap', () => { 19 | describe('getCombinedSource', () => { 20 | it('returns combined source files', () => { 21 | const sourceMap = new SourceMap(sourceFiles); 22 | 23 | assert.equal( 24 | sourceMap.getCombinedSource(), 25 | `type Query { 26 | a: String! 27 | } 28 | type User { 29 | username: String! 30 | email: String! 31 | } 32 | schema { query: Query } 33 | type Comment { user: User! body: String! }` 34 | ); 35 | }); 36 | }); 37 | 38 | describe('getOriginalPathForLine', () => { 39 | it('returns the path of the file that contains the source on the specified line number of the combined file', () => { 40 | const sourceMap = new SourceMap(sourceFiles); 41 | 42 | assert.equal('query.graphql', sourceMap.getOriginalPathForLine(1)); 43 | assert.equal('query.graphql', sourceMap.getOriginalPathForLine(2)); 44 | assert.equal('query.graphql', sourceMap.getOriginalPathForLine(3)); 45 | assert.equal('user.graphql', sourceMap.getOriginalPathForLine(4)); 46 | assert.equal('user.graphql', sourceMap.getOriginalPathForLine(5)); 47 | assert.equal('user.graphql', sourceMap.getOriginalPathForLine(6)); 48 | assert.equal('user.graphql', sourceMap.getOriginalPathForLine(7)); 49 | assert.equal('schema.graphql', sourceMap.getOriginalPathForLine(8)); 50 | assert.equal('comment.graphql', sourceMap.getOriginalPathForLine(9)); 51 | }); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/rules/defined_types_are_used.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | export function DefinedTypesAreUsed(context) { 4 | var ignoredTypes = ['Query', 'Mutation', 'Subscription']; 5 | var definedTypes = []; 6 | var referencedTypes = new Set(); 7 | 8 | var recordDefinedType = (node) => { 9 | if (ignoredTypes.indexOf(node.name.value) == -1) { 10 | definedTypes.push(node); 11 | } 12 | }; 13 | 14 | return { 15 | ScalarTypeDefinition: recordDefinedType, 16 | ObjectTypeDefinition: recordDefinedType, 17 | InterfaceTypeDefinition: recordDefinedType, 18 | UnionTypeDefinition: recordDefinedType, 19 | EnumTypeDefinition: recordDefinedType, 20 | InputObjectTypeDefinition: recordDefinedType, 21 | 22 | NamedType: (node, key, parent, path, ancestors) => { 23 | referencedTypes.add(node.name.value); 24 | }, 25 | 26 | Document: { 27 | leave: (node) => { 28 | definedTypes.forEach((node) => { 29 | if (node.kind == 'ObjectTypeDefinition') { 30 | let implementedInterfaces = node.interfaces.map((node) => { 31 | return node.name.value; 32 | }); 33 | 34 | let anyReferencedInterfaces = implementedInterfaces.some( 35 | (interfaceName) => { 36 | return referencedTypes.has(interfaceName); 37 | } 38 | ); 39 | 40 | if (anyReferencedInterfaces) { 41 | return; 42 | } 43 | } 44 | 45 | if (!referencedTypes.has(node.name.value)) { 46 | context.reportError( 47 | new ValidationError( 48 | 'defined-types-are-used', 49 | `The type \`${node.name.value}\` is defined in the ` + 50 | `schema but not used anywhere.`, 51 | [node] 52 | ) 53 | ); 54 | } 55 | }); 56 | }, 57 | }, 58 | }; 59 | } 60 | -------------------------------------------------------------------------------- /test/config/rc_file/test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Configuration } from '../../../src/configuration'; 3 | import { emptySchema } from '../../../src/schema'; 4 | import { loadOptionsFromConfigDir } from '../../../src/options'; 5 | 6 | describe('Config', () => { 7 | describe('getRules', () => { 8 | it('pulls rule config from a .graphql-schema-linterrc dotfile', () => { 9 | const options = loadOptionsFromConfigDir(__dirname); 10 | const configuration = new Configuration(emptySchema, options); 11 | 12 | const rules = configuration.getRules(); 13 | 14 | assert.equal(rules.length, 1); 15 | assert.equal( 16 | 1, 17 | rules.filter((rule) => { 18 | return rule.name == 'EnumValuesSortedAlphabetically'; 19 | }).length 20 | ); 21 | }); 22 | }); 23 | 24 | describe('getRulesOptions', () => { 25 | it('pulls rule config from a .graphql-schema-linterrc dotfile', () => { 26 | const options = loadOptionsFromConfigDir(__dirname); 27 | const configuration = new Configuration(emptySchema, options); 28 | 29 | const rulesOptions = configuration.getRulesOptions(); 30 | 31 | assert.equal(1, Object.entries(rulesOptions).length); 32 | assert.deepEqual(rulesOptions, { 33 | 'enum-values-sorted-alphabetically': { sortOrder: 'lexicographical' }, 34 | }); 35 | }); 36 | }); 37 | 38 | describe('getIgnoreList', () => { 39 | it('pulls ignore list from a .graphql-schema-linterrc dotfile', () => { 40 | const options = loadOptionsFromConfigDir(__dirname); 41 | const configuration = new Configuration(emptySchema, options); 42 | 43 | const ignoreList = configuration.getIgnoreList(); 44 | 45 | assert.deepEqual(ignoreList, { 46 | 'fields-have-descriptions': [ 47 | 'Obvious', 48 | 'Query.obvious', 49 | 'Query.something.obvious', 50 | ], 51 | }); 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /test/rules/arguments_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { version } from 'graphql'; 2 | import { ArgumentsHaveDescriptions } from '../../src/rules/arguments_have_descriptions'; 3 | import { 4 | expectFailsRule, 5 | expectPassesRuleWithConfiguration, 6 | } from '../assertions'; 7 | 8 | const itPotentially = version.startsWith('15.') ? it : it.skip; 9 | 10 | describe('ArgumentsHaveDescriptions rule', () => { 11 | it('catches field arguments that have no description', () => { 12 | expectFailsRule( 13 | ArgumentsHaveDescriptions, 14 | ` 15 | type Box { 16 | widget( 17 | id: Int 18 | 19 | "Widget type" 20 | type: String 21 | ): String! 22 | } 23 | `, 24 | [ 25 | { 26 | message: 'The `id` argument of `widget` is missing a description.', 27 | locations: [{ line: 4, column: 11 }], 28 | }, 29 | ] 30 | ); 31 | }); 32 | 33 | it('catches field arguments that have empty description', () => { 34 | expectFailsRule( 35 | ArgumentsHaveDescriptions, 36 | ` 37 | type Box { 38 | widget( 39 | "" 40 | id: Int 41 | 42 | "Widget type" 43 | type: String 44 | ): String! 45 | } 46 | `, 47 | [ 48 | { 49 | message: 'The `id` argument of `widget` is missing a description.', 50 | locations: [{ line: 4, column: 11 }], 51 | }, 52 | ] 53 | ); 54 | }); 55 | 56 | itPotentially( 57 | 'gets descriptions correctly with commentDescriptions option', 58 | () => { 59 | expectPassesRuleWithConfiguration( 60 | ArgumentsHaveDescriptions, 61 | ` 62 | type Box { 63 | widget( 64 | "Widget ID" 65 | id: Int 66 | 67 | # Widget type 68 | type: String 69 | ): String! 70 | } 71 | `, 72 | { commentDescriptions: true } 73 | ); 74 | } 75 | ); 76 | }); 77 | -------------------------------------------------------------------------------- /test/helpers.js: -------------------------------------------------------------------------------- 1 | import { writeFileSync, mkdtempSync } from 'fs'; 2 | import os from 'os'; 3 | import path from 'path'; 4 | 5 | const TEMP_DIR_PREFIX = '.mockedTests-'; 6 | const writeJSONFile = (filename, data) => 7 | writeFileSync(filename, JSON.stringify(data)); 8 | 9 | /** 10 | * There's some magic in order to create a relative path to the temporal directory. but mostly it does 3 things. 11 | * 1) gets the dirname of the given path. 12 | * 2) finds the relative from the temp file to the dirname found in step 1) 13 | * 3) joins relativePath to the remaining fullPath (minus the dirnamePath) 14 | */ 15 | 16 | const getRelocatedPath = (dir, fullPath) => { 17 | const dirnamePath = path.dirname(fullPath); 18 | const relativePath = path.relative(dir, dirnamePath); 19 | return path.join(path.join(relativePath, fullPath.split(dirnamePath)[1])); 20 | }; 21 | 22 | export const temporaryConfigDirectory = ({ 23 | rules = null, 24 | rulesOptions = null, 25 | ignore = null, 26 | customRulePaths = [], 27 | schemaPaths = [], 28 | }) => { 29 | const configDirectory = mkdtempSync(path.join(os.tmpdir(), TEMP_DIR_PREFIX)); 30 | let fixCustomRulePaths = []; 31 | let fixedSchemaPaths = []; 32 | const options = { rules, rulesOptions, ignore }; 33 | 34 | // due to the temp nature of the directory creation we ought to fix the provided paths to match the location. 35 | if (customRulePaths.length) { 36 | fixCustomRulePaths = customRulePaths.map((rulePath) => 37 | getRelocatedPath(configDirectory, rulePath) 38 | ); 39 | 40 | options.customRulePaths = fixCustomRulePaths; 41 | } 42 | 43 | if (schemaPaths.length) { 44 | fixedSchemaPaths = schemaPaths.map((globPath) => 45 | getRelocatedPath(configDirectory, globPath) 46 | ); 47 | 48 | options.schemaPaths = fixedSchemaPaths; 49 | } 50 | 51 | writeJSONFile(path.join(configDirectory, 'package.json'), { 52 | 'graphql-schema-linter': { ...options }, 53 | }); 54 | return configDirectory; 55 | }; 56 | -------------------------------------------------------------------------------- /test/config/js_file/test.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Configuration } from '../../../src/configuration'; 3 | import { emptySchema } from '../../../src/schema'; 4 | import { loadOptionsFromConfigDir } from '../../../src/options'; 5 | 6 | describe('Config', () => { 7 | describe('getRules', () => { 8 | it('pulls rule config from a *.config.js', () => { 9 | const options = loadOptionsFromConfigDir(__dirname); 10 | const configuration = new Configuration(emptySchema, options); 11 | 12 | const rules = configuration.getRules(); 13 | 14 | assert.equal(rules.length, 2); 15 | assert.equal( 16 | 2, 17 | rules.filter((rule) => { 18 | return ( 19 | rule.name == 'EnumValuesSortedAlphabetically' || 20 | rule.name == 'EnumNameCannotContainEnum' 21 | ); 22 | }).length 23 | ); 24 | }); 25 | }); 26 | 27 | describe('getRulesOptions', () => { 28 | it('pulls rulesOptions config from a *.config.js', () => { 29 | const options = loadOptionsFromConfigDir(__dirname); 30 | const configuration = new Configuration(emptySchema, options); 31 | const rulesOptions = configuration.getRulesOptions(); 32 | 33 | assert.equal(Object.entries(rulesOptions).length, 1); 34 | assert.deepEqual(rulesOptions, { 35 | 'enum-values-sorted-alphabetically': { 36 | sortOrder: 'lexicographical', 37 | }, 38 | }); 39 | }); 40 | }); 41 | 42 | describe('getIgnoreList', () => { 43 | it('pulls ignore list from a *.config.js', () => { 44 | const options = loadOptionsFromConfigDir(__dirname); 45 | const configuration = new Configuration(emptySchema, options); 46 | 47 | const ignoreList = configuration.getIgnoreList(); 48 | 49 | assert.deepEqual(ignoreList, { 50 | 'fields-have-descriptions': [ 51 | 'Obvious', 52 | 'Query.obvious', 53 | 'Query.something.obvious', 54 | ], 55 | }); 56 | }); 57 | }); 58 | }); 59 | -------------------------------------------------------------------------------- /test/rules/deprecations_have_a_reason.js: -------------------------------------------------------------------------------- 1 | import { DeprecationsHaveAReason } from '../../src/rules/deprecations_have_a_reason'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('DeprecationsHaveAReason rule', () => { 5 | it('catches deprecated fields that have no deprecation reason in object types', () => { 6 | expectFailsRule( 7 | DeprecationsHaveAReason, 8 | ` 9 | type A { 10 | deprecatedWithoutReason: String @deprecated 11 | deprecatedWithReason: String @deprecated(reason: "Reason") 12 | notDeprecated: String 13 | } 14 | `, 15 | [ 16 | { 17 | message: 18 | 'The field `A.deprecatedWithoutReason` is deprecated but has no deprecation reason.', 19 | locations: [{ line: 3, column: 41 }], 20 | }, 21 | ] 22 | ); 23 | }); 24 | 25 | it('catches deprecated fields that have no deprecation reason in interface types', () => { 26 | expectFailsRule( 27 | DeprecationsHaveAReason, 28 | ` 29 | interface A { 30 | deprecatedWithoutReason: String @deprecated 31 | deprecatedWithReason: String @deprecated(reason: "Reason") 32 | notDeprecated: String 33 | } 34 | `, 35 | [ 36 | { 37 | message: 38 | 'The field `A.deprecatedWithoutReason` is deprecated but has no deprecation reason.', 39 | locations: [{ line: 3, column: 41 }], 40 | }, 41 | ] 42 | ); 43 | }); 44 | 45 | it('catches deprecated enum values that have no deprecation reason', () => { 46 | expectFailsRule( 47 | DeprecationsHaveAReason, 48 | ` 49 | enum A { 50 | deprecatedWithoutReason @deprecated 51 | deprecatedWithReason @deprecated(reason: "Reason") 52 | notDeprecated 53 | } 54 | `, 55 | [ 56 | { 57 | message: 58 | 'The enum value `A.deprecatedWithoutReason` is deprecated but has no deprecation reason.', 59 | 60 | locations: [{ line: 3, column: 33 }], 61 | }, 62 | ] 63 | ); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /test/rules/descriptions_are_capitalized.js: -------------------------------------------------------------------------------- 1 | import { version } from 'graphql'; 2 | import { DescriptionsAreCapitalized } from '../../src/rules/descriptions_are_capitalized'; 3 | import { 4 | expectFailsRule, 5 | expectFailsRuleWithConfiguration, 6 | expectPassesRule, 7 | } from '../assertions'; 8 | 9 | const itPotentially = version.startsWith('15.') ? it : it.skip; 10 | 11 | describe('DescriptionsAreCapitalized rule', () => { 12 | it('detects lowercase field descriptions', () => { 13 | expectFailsRule( 14 | DescriptionsAreCapitalized, 15 | ` 16 | type Widget { 17 | "widget name" 18 | name: String! 19 | 20 | "Valid description" 21 | other: Int 22 | } 23 | `, 24 | [ 25 | { 26 | message: 27 | 'The description for field `Widget.name` should be capitalized.', 28 | locations: [{ line: 3, column: 9 }], 29 | }, 30 | ] 31 | ); 32 | }); 33 | 34 | itPotentially( 35 | 'detects lowercase field descriptions with commentDescriptions option', 36 | () => { 37 | expectFailsRuleWithConfiguration( 38 | DescriptionsAreCapitalized, 39 | ` 40 | type Widget { 41 | # widget name 42 | name: String! 43 | 44 | # Valid description 45 | other: Int 46 | } 47 | `, 48 | { commentDescriptions: true }, 49 | [ 50 | { 51 | message: 52 | 'The description for field `Widget.name` should be capitalized.', 53 | locations: [{ line: 4, column: 9 }], 54 | }, 55 | ] 56 | ); 57 | } 58 | ); 59 | 60 | it('does not err on an empty description', () => { 61 | expectPassesRule( 62 | DescriptionsAreCapitalized, 63 | ` 64 | type Widget { 65 | "" 66 | name: String! 67 | } 68 | ` 69 | ); 70 | }); 71 | 72 | it('does not err on a missing description', () => { 73 | expectPassesRule( 74 | DescriptionsAreCapitalized, 75 | ` 76 | type Widget { 77 | name: String! 78 | } 79 | ` 80 | ); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/rules/deprecations_have_a_reason.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | export function DeprecationsHaveAReason(context) { 4 | return { 5 | FieldDefinition(node, key, parent, path, ancestors) { 6 | const deprecatedDirective = getDeprecatedDirective(node); 7 | if (!deprecatedDirective) { 8 | return; 9 | } 10 | 11 | const reasonArgument = getReasonArgument(deprecatedDirective); 12 | if (reasonArgument) { 13 | return; 14 | } 15 | 16 | const fieldName = node.name.value; 17 | const parentName = ancestors[ancestors.length - 1].name.value; 18 | 19 | context.reportError( 20 | new ValidationError( 21 | 'deprecations-have-a-reason', 22 | `The field \`${parentName}.${fieldName}\` is deprecated but has no deprecation reason.`, 23 | [deprecatedDirective] 24 | ) 25 | ); 26 | }, 27 | 28 | EnumValueDefinition(node, key, parent, path, ancestors) { 29 | const deprecatedDirective = getDeprecatedDirective(node); 30 | if (!deprecatedDirective) { 31 | return; 32 | } 33 | 34 | const reasonArgument = getReasonArgument(deprecatedDirective); 35 | if (reasonArgument) { 36 | return; 37 | } 38 | 39 | const enumValueName = node.name.value; 40 | const parentName = ancestors[ancestors.length - 1].name.value; 41 | 42 | context.reportError( 43 | new ValidationError( 44 | 'deprecations-have-a-reason', 45 | `The enum value \`${parentName}.${enumValueName}\` is deprecated but has no deprecation reason.`, 46 | [deprecatedDirective] 47 | ) 48 | ); 49 | }, 50 | }; 51 | } 52 | 53 | function getDeprecatedDirective(node) { 54 | const deprecatedDirective = node.directives.find((directive) => { 55 | if (directive.name.value != 'deprecated') { 56 | return false; 57 | } 58 | 59 | return true; 60 | }); 61 | 62 | return deprecatedDirective; 63 | } 64 | 65 | function getReasonArgument(deprecatedDirective) { 66 | const reasonArgument = deprecatedDirective.arguments.find((arg) => { 67 | if (arg.name.value == 'reason') { 68 | return true; 69 | } 70 | 71 | return false; 72 | }); 73 | 74 | return reasonArgument; 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-schema-linter", 3 | "version": "3.0.1", 4 | "description": "Command line tool and package to validate GraphQL schemas against a set of rules.", 5 | "author": "Christian Joudrey", 6 | "main": "lib/index.js", 7 | "scripts": { 8 | "test": "mocha test/index.js && yarn test:integration", 9 | "test:integration": "node -e 'console.log(\"GraphQL version: \", require(\"graphql\").version);' node lib/cli.js --version && node lib/cli.js test/fixtures/valid.graphql && (cat test/fixtures/valid.graphql | node lib/cli.js --stdin)", 10 | "test:ci": "yarn test && yarn test:ci:graphql-v15.x && yarn test:ci:graphql-v16.x", 11 | "test:ci:graphql-v15.x": "yarn upgrade graphql@^15.0.0 && yarn test", 12 | "test:ci:graphql-v16.x": "yarn upgrade graphql@^16.0.0 && yarn test", 13 | "prepare": "rm -rf lib/* && babel ./src --ignore test --out-dir ./lib" 14 | }, 15 | "pkg": { 16 | "scripts": "lib/**/*.js" 17 | }, 18 | "homepage": "https://github.com/cjoudrey/graphql-schema-linter", 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/cjoudrey/graphql-schema-linter.git" 22 | }, 23 | "peerDependencies": { 24 | "graphql": "^15.0.0 || ^16.0.0" 25 | }, 26 | "devDependencies": { 27 | "@babel/cli": "7.17.10", 28 | "@babel/core": "7.18.0", 29 | "@babel/preset-env": "7.18.0", 30 | "@babel/register": "7.17.7", 31 | "graphql": "=15.0.0", 32 | "husky": "4.3.8", 33 | "lint-staged": "10.5.4", 34 | "mocha": "10.0.0", 35 | "prettier": "2.6.2" 36 | }, 37 | "babel": { 38 | "presets": [ 39 | [ 40 | "@babel/preset-env", 41 | { 42 | "targets": { 43 | "node": "9.4" 44 | } 45 | } 46 | ] 47 | ] 48 | }, 49 | "license": "MIT", 50 | "dependencies": { 51 | "chalk": "^2.0.1", 52 | "columnify": "^1.5.4", 53 | "commander": "^3.0.0", 54 | "cosmiconfig": "^5.2.1", 55 | "glob": "^7.1.2" 56 | }, 57 | "bin": { 58 | "graphql-schema-linter": "lib/cli.js" 59 | }, 60 | "lint-staged": { 61 | "*.{js,json}": [ 62 | "prettier --write" 63 | ] 64 | }, 65 | "prettier": { 66 | "singleQuote": true, 67 | "trailingComma": "es5" 68 | }, 69 | "husky": { 70 | "hooks": { 71 | "pre-commit": "lint-staged" 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /test/formatters/text_formatter.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import TextFormatter from '../../src/formatters/text_formatter'; 3 | import figures from '../../src/figures'; 4 | const stripAnsi = require('strip-ansi'); 5 | 6 | describe('TextFormatter', () => { 7 | it('returns a summary when there are no errors', () => { 8 | const expected = '\n' + `\n${figures.tick} 0 errors detected\n\n`; 9 | 10 | assert.equal(stripAnsi(TextFormatter({})), expected); 11 | }); 12 | 13 | it('returns error and singular summary when there is one error', () => { 14 | const errors = { 15 | 'schema/query.graphql': [ 16 | { 17 | message: 18 | 'The field `Query.users` is deprecated but has no deprecation reason.', 19 | locations: [{ line: 4, column: 20 }], 20 | ruleName: 'deprecations-have-a-reason', 21 | }, 22 | ], 23 | }; 24 | 25 | const expected = 26 | '' + 27 | 'schema/query.graphql\n' + 28 | '4:20 The field `Query.users` is deprecated but has no deprecation reason. deprecations-have-a-reason\n' + 29 | '\n' + 30 | `${figures.cross} 1 error detected\n`; 31 | 32 | assert.equal(stripAnsi(TextFormatter(errors)), expected); 33 | }); 34 | 35 | it('returns errors and plural summary when there is more than one error', () => { 36 | const errors = { 37 | 'schema/query.graphql': [ 38 | { 39 | message: 40 | 'The field `Query.users` is deprecated but has no deprecation reason.', 41 | locations: [{ line: 4, column: 20 }], 42 | ruleName: 'deprecations-have-a-reason', 43 | }, 44 | ], 45 | 46 | 'schema/user.graphql': [ 47 | { 48 | message: 'The field `User.email` is missing a description.', 49 | locations: [{ line: 3, column: 3 }], 50 | ruleName: 'fields-have-descriptions', 51 | }, 52 | ], 53 | }; 54 | 55 | const expected = 56 | '' + 57 | 'schema/query.graphql\n' + 58 | '4:20 The field `Query.users` is deprecated but has no deprecation reason. deprecations-have-a-reason\n' + 59 | '\n' + 60 | 'schema/user.graphql\n' + 61 | '3:3 The field `User.email` is missing a description. fields-have-descriptions\n' + 62 | '\n' + 63 | `${figures.cross} 2 errors detected\n`; 64 | 65 | assert.equal(stripAnsi(TextFormatter(errors)), expected); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/rules/relay_page_info_spec.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | import { print } from 'graphql/language/printer'; 3 | 4 | export function RelayPageInfoSpec(context) { 5 | return { 6 | Document: { 7 | leave: function (node) { 8 | const pageInfoType = context.getSchema().getType('PageInfo'); 9 | 10 | if (!pageInfoType) { 11 | return context.reportError( 12 | new ValidationError( 13 | 'relay-page-info-spec', 14 | 'A `PageInfo` object type is required as per the Relay spec.', 15 | [node] 16 | ) 17 | ); 18 | } 19 | 20 | const pageInfoFields = pageInfoType.getFields(); 21 | 22 | const hasPreviousPageField = pageInfoFields['hasPreviousPage']; 23 | 24 | if (!hasPreviousPageField) { 25 | context.reportError( 26 | new ValidationError( 27 | 'relay-page-info-spec', 28 | 'The `PageInfo` object type must have a `hasPreviousPage` field that returns a non-null Boolean as per the Relay spec.', 29 | [pageInfoType.astNode] 30 | ) 31 | ); 32 | } else if (hasPreviousPageField.type != 'Boolean!') { 33 | context.reportError( 34 | new ValidationError( 35 | 'relay-page-info-spec', 36 | 'The `PageInfo` object type must have a `hasPreviousPage` field that returns a non-null Boolean as per the Relay spec.', 37 | [hasPreviousPageField.astNode] 38 | ) 39 | ); 40 | } 41 | 42 | const hasNextPageField = pageInfoFields['hasNextPage']; 43 | 44 | if (!hasNextPageField) { 45 | context.reportError( 46 | new ValidationError( 47 | 'relay-page-info-spec', 48 | 'The `PageInfo` object type must have a `hasNextPage` field that returns a non-null Boolean as per the Relay spec.', 49 | [pageInfoType.astNode] 50 | ) 51 | ); 52 | } else if (hasNextPageField.type != 'Boolean!') { 53 | context.reportError( 54 | new ValidationError( 55 | 'relay-page-info-spec', 56 | 'The `PageInfo` object type must have a `hasNextPage` field that returns a non-null Boolean as per the Relay spec.', 57 | [hasNextPageField.astNode] 58 | ) 59 | ); 60 | } 61 | }, 62 | }, 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /test/schema.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { relative as pathRelative } from 'path'; 3 | import { loadSchema } from '../src/schema'; 4 | import { createReadStream, readFileSync } from 'fs'; 5 | 6 | describe('loadSchema', () => { 7 | it('concatenates multiple files when given a glob', async () => { 8 | const schemaPath = `${__dirname}/fixtures/schema/**/*.graphql`; 9 | 10 | const expectedSchema = `type Comment { 11 | body: String! 12 | author: User! 13 | } 14 | 15 | # lint-disable fields-have-descriptions 16 | extend type Query { 17 | comments: [Comment!]! 18 | } 19 | # lint-enable fields-have-descriptions 20 | 21 | type Post { 22 | id: ID! 23 | title: String! # lint-disable-line fields-have-descriptions 24 | description: String! 25 | author: User! 26 | } 27 | 28 | type Obvious { 29 | one: String! 30 | two: Int 31 | } 32 | 33 | type DontPanic { 34 | obvious: Boolean 35 | } 36 | 37 | type Query { 38 | something: String! 39 | } 40 | 41 | schema { 42 | query: Query 43 | } 44 | 45 | type User { 46 | username: String! 47 | email: String! 48 | } 49 | 50 | extend type Query { 51 | viewer: User! 52 | } 53 | `; 54 | 55 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 56 | assert.equal(schema.definition, expectedSchema); 57 | }); 58 | 59 | it('reads schema from file when provided', async () => { 60 | const fixturePath = `${__dirname}/fixtures/schema.graphql`; 61 | const schema = await loadSchema({ schemaPaths: [fixturePath] }); 62 | assert.equal(schema.definition, readFileSync(fixturePath).toString('utf8')); 63 | }); 64 | 65 | it('reads schema from stdin when --stdin is set', async () => { 66 | const fixturePath = `${__dirname}/fixtures/schema.graphql`; 67 | const stdin = createReadStream(fixturePath); 68 | 69 | const schema = await loadSchema({ stdin: true }, stdin); 70 | assert.equal(schema.definition, readFileSync(fixturePath).toString('utf8')); 71 | }); 72 | 73 | it('normalizes schema files paths', async () => { 74 | const fixturePath = `${__dirname}/fixtures/schema.graphql`; 75 | const duplicatePath = pathRelative( 76 | process.cwd(), 77 | `${__dirname}/fixtures/schema.graphql` 78 | ); 79 | 80 | assert.notEqual(fixturePath, duplicatePath); 81 | 82 | const schema = await loadSchema({ 83 | schemaPaths: [fixturePath, duplicatePath], 84 | }); 85 | 86 | assert.equal(schema.definition, readFileSync(fixturePath).toString('utf8')); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /test/assertions.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { parse } from 'graphql'; 3 | import { validate } from 'graphql/validation'; 4 | import { buildASTSchema } from 'graphql/utilities/buildASTSchema'; 5 | import { validateSchemaDefinition } from '../src/validator.js'; 6 | import { Configuration } from '../src/configuration.js'; 7 | import { Schema } from '../src/schema.js'; 8 | 9 | const DefaultSchema = ` 10 | "Query root" 11 | type Query { 12 | "Field" 13 | a: String 14 | } 15 | `; 16 | 17 | export function expectFailsRule( 18 | rule, 19 | schemaSDL, 20 | expectedErrors = [], 21 | configurationOptions = {} 22 | ) { 23 | return expectFailsRuleWithConfiguration( 24 | rule, 25 | schemaSDL, 26 | configurationOptions, 27 | expectedErrors 28 | ); 29 | } 30 | 31 | export function expectFailsRuleWithConfiguration( 32 | rule, 33 | schemaSDL, 34 | configurationOptions, 35 | expectedErrors = [] 36 | ) { 37 | const errors = validateSchemaWithRule(rule, schemaSDL, configurationOptions); 38 | 39 | assert(errors.length > 0, "Expected rule to fail but didn't"); 40 | 41 | assert.deepEqual( 42 | errors, 43 | expectedErrors.map((expectedError) => { 44 | return Object.assign(expectedError, { 45 | ruleName: rule.name 46 | .replace(/([A-Z])/g, '-$1') 47 | .toLowerCase() 48 | .replace(/^-/, ''), 49 | }); 50 | }) 51 | ); 52 | } 53 | 54 | export function expectPassesRule(rule, schemaSDL, configurationOptions = {}) { 55 | expectPassesRuleWithConfiguration(rule, schemaSDL, configurationOptions); 56 | } 57 | 58 | export function expectPassesRuleWithConfiguration( 59 | rule, 60 | schemaSDL, 61 | configurationOptions 62 | ) { 63 | const errors = validateSchemaWithRule(rule, schemaSDL, configurationOptions); 64 | 65 | assert( 66 | errors.length == 0, 67 | `Expected rule to pass but didn't got these errors:\n\n${errors.join('\n')}` 68 | ); 69 | } 70 | 71 | function validateSchemaWithRule(rule, schemaSDL, configurationOptions) { 72 | const rules = [rule]; 73 | const schema = new Schema(`${schemaSDL}${DefaultSchema}`, null); 74 | const configuration = new Configuration(schema, configurationOptions); 75 | const errors = validateSchemaDefinition(schema, rules, configuration); 76 | const transformedErrors = errors.map((error) => ({ 77 | locations: error.locations, 78 | message: error.message, 79 | ruleName: error.ruleName, 80 | })); 81 | 82 | return transformedErrors; 83 | } 84 | -------------------------------------------------------------------------------- /test/rules/interface_fields_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { InterfaceFieldsSortedAlphabetically } from '../../src/rules/interface_fields_sorted_alphabetically'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('InterfaceFieldsSortedAlphabetically rule', () => { 5 | describe('when sortOrder is alphabetical', () => { 6 | it('catches interface fields are not sorted alphabetically', () => { 7 | expectFailsRule( 8 | InterfaceFieldsSortedAlphabetically, 9 | ` 10 | interface Error { 11 | b: String 12 | a: String 13 | } 14 | `, 15 | [ 16 | { 17 | message: 18 | 'The fields of interface type `Error` should be sorted in alphabetical order. Expected sorting: a, b', 19 | locations: [{ line: 2, column: 9 }], 20 | ruleName: 'interface-fields-sorted-alphabetically', 21 | }, 22 | ] 23 | ); 24 | }); 25 | 26 | it('allows interfaces that are sorted alphabetically ', () => { 27 | expectPassesRule( 28 | InterfaceFieldsSortedAlphabetically, 29 | ` 30 | interface Error { 31 | a: String 32 | b: String 33 | } 34 | ` 35 | ); 36 | }); 37 | }); 38 | 39 | describe('when sortOrder is lexicographical', () => { 40 | it('catches interface fields are not sorted lexicographically', () => { 41 | expectFailsRule( 42 | InterfaceFieldsSortedAlphabetically, 43 | ` 44 | interface Error { 45 | ZZZ: String 46 | AAA: String 47 | AA_: String 48 | aaa: String 49 | } 50 | `, 51 | [ 52 | { 53 | message: 54 | 'The fields of interface type `Error` should be sorted in lexicographical order. Expected sorting: AA_, aaa, AAA, ZZZ', 55 | locations: [{ line: 2, column: 9 }], 56 | ruleName: 'interface-fields-sorted-alphabetically', 57 | }, 58 | ], 59 | { 60 | rulesOptions: { 61 | 'interface-fields-sorted-alphabetically': { 62 | sortOrder: 'lexicographical', 63 | }, 64 | }, 65 | } 66 | ); 67 | }); 68 | 69 | it('allows interfaces that are sorted lexicographically', () => { 70 | expectPassesRule( 71 | InterfaceFieldsSortedAlphabetically, 72 | ` 73 | interface Error { 74 | AA_: String 75 | aaa: String 76 | AAA: String 77 | ZZZ: String 78 | } 79 | `, 80 | { 81 | rulesOptions: { 82 | 'interface-fields-sorted-alphabetically': { 83 | sortOrder: 'lexicographical', 84 | }, 85 | }, 86 | } 87 | ); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/schema.js: -------------------------------------------------------------------------------- 1 | import { readSync, readFileSync } from 'fs'; 2 | 3 | import { SourceMap } from './source_map.js'; 4 | import expandPaths from './util/expandPaths.js'; 5 | 6 | export class Schema { 7 | constructor(definition, sourceMap) { 8 | this.definition = definition; 9 | this.sourceMap = sourceMap; 10 | } 11 | } 12 | 13 | // emptySchema is an empty schema for use in tests, when it's known that the 14 | // schema won't be used. If any of the schema properties are accessed an error 15 | // is thrown. 16 | export const emptySchema = { 17 | get definition() { 18 | throw Error('cannot get definition for empty schema'); 19 | }, 20 | get sourceMap() { 21 | throw Error('cannot get source map for empty schema'); 22 | }, 23 | }; 24 | 25 | export async function loadSchema(options, stdin) { 26 | /* 27 | options: 28 | - schemaPaths: [string array] file(s) to read schema from 29 | - stdin: [boolean] read the schema from stdin? 30 | */ 31 | if (options.stdin && stdin) { 32 | return await loadSchemaFromStdin(stdin); 33 | } else if (options.schemaPaths) { 34 | return loadSchemaFromPaths(options.schemaPaths); 35 | } 36 | 37 | return null; 38 | } 39 | 40 | async function loadSchemaFromStdin(stdin) { 41 | const definition = await loadDefinitionFromStream(stdin); 42 | 43 | if (definition === null) { 44 | return null; 45 | } 46 | 47 | const sourceMap = new SourceMap({ stdin: definition }); 48 | 49 | return new Schema(definition, sourceMap); 50 | } 51 | 52 | async function loadDefinitionFromStream(stream) { 53 | return new Promise((resolve, reject) => { 54 | let data = Buffer.alloc(0); 55 | 56 | stream.on('data', chunk => { 57 | data = Buffer.concat([data, chunk]); 58 | }); 59 | 60 | stream.on('end', () => { 61 | // We must not convert data to a utf8 string util we have all of the 62 | // bytes. Chunks may not end on sequence boundaries. 63 | resolve(data.length > 0 ? data.toString('utf8') : null); 64 | }); 65 | }); 66 | } 67 | 68 | function loadSchemaFromPaths(paths) { 69 | const expandedPaths = expandPaths(paths); 70 | const segments = getDefinitionSegmentsFromFiles(expandedPaths); 71 | 72 | if (Object.keys(segments).length === 0) { 73 | return null; 74 | } 75 | 76 | const sourceMap = new SourceMap(segments); 77 | const definition = sourceMap.getCombinedSource(); 78 | 79 | return new Schema(definition, sourceMap); 80 | } 81 | 82 | function getDefinitionFromFile(path) { 83 | try { 84 | return readFileSync(path).toString('utf8'); 85 | } catch (e) { 86 | console.error(e.message); 87 | } 88 | return null; 89 | } 90 | 91 | function getDefinitionSegmentsFromFiles(paths) { 92 | return paths.reduce((segments, path) => { 93 | let definition = getDefinitionFromFile(path); 94 | if (definition) { 95 | segments[path] = definition; 96 | } 97 | return segments; 98 | }, {}); 99 | } 100 | -------------------------------------------------------------------------------- /test/rules/enum_values_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { EnumValuesSortedAlphabetically } from '../../src/rules/enum_values_sorted_alphabetically'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('EnumValuesSortedAlphabetically rule', () => { 5 | describe('when sortOrder is alphabetical', () => { 6 | it('catches enums that are not sorted alphabetically', () => { 7 | expectFailsRule( 8 | EnumValuesSortedAlphabetically, 9 | ` 10 | enum Stage { 11 | ZZZ 12 | AAA 13 | AA_ 14 | aaa 15 | } 16 | `, 17 | [ 18 | { 19 | message: 20 | 'The enum `Stage` should be sorted in alphabetical order. Expected sorting: AAA, AA_, ZZZ, aaa', 21 | locations: [{ line: 2, column: 9 }], 22 | }, 23 | ], 24 | { 25 | rulesOptions: { 26 | 'enum-values-sorted-alphabetically': { 27 | sortOrder: 'alphabetical', 28 | }, 29 | }, 30 | } 31 | ); 32 | }); 33 | 34 | it('allows enums that are sorted alphabetically ', () => { 35 | expectPassesRule( 36 | EnumValuesSortedAlphabetically, 37 | ` 38 | enum Stage { 39 | AAA 40 | AA_ 41 | ZZZ 42 | aaa 43 | } 44 | `, 45 | { 46 | rulesOptions: { 47 | 'enum-values-sorted-alphabetically': { 48 | sortOrder: 'alphabetical', 49 | }, 50 | }, 51 | } 52 | ); 53 | }); 54 | }); 55 | 56 | describe('when sortOrder is lexicographical', () => { 57 | it('catches enums that are not sorted lexicographically', () => { 58 | expectFailsRule( 59 | EnumValuesSortedAlphabetically, 60 | ` 61 | enum Stage { 62 | ZZZ 63 | AAA 64 | AA_ 65 | aaa 66 | } 67 | `, 68 | [ 69 | { 70 | message: 71 | 'The enum `Stage` should be sorted in lexicographical order. Expected sorting: AA_, aaa, AAA, ZZZ', 72 | locations: [{ line: 2, column: 9 }], 73 | }, 74 | ], 75 | { 76 | rulesOptions: { 77 | 'enum-values-sorted-alphabetically': { 78 | sortOrder: 'lexicographical', 79 | }, 80 | }, 81 | } 82 | ); 83 | }); 84 | 85 | it('allows enums that are sorted lexicographically ', () => { 86 | expectPassesRule( 87 | EnumValuesSortedAlphabetically, 88 | ` 89 | enum Stage { 90 | AA_ 91 | aaa 92 | AAA 93 | ZZZ 94 | } 95 | `, 96 | { 97 | rulesOptions: { 98 | 'enum-values-sorted-alphabetically': { 99 | sortOrder: 'lexicographical', 100 | }, 101 | }, 102 | } 103 | ); 104 | }); 105 | }); 106 | }); 107 | -------------------------------------------------------------------------------- /src/rules/relay_connection_types_spec.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | import { print } from 'graphql/language/printer'; 3 | 4 | const MANDATORY_FIELDS = ['pageInfo', 'edges']; 5 | 6 | export function RelayConnectionTypesSpec(context) { 7 | var ensureNameDoesNotEndWithConnection = (node) => { 8 | if (node.name.value.match(/Connection$/)) { 9 | context.reportError( 10 | new ValidationError( 11 | 'relay-connection-types-spec', 12 | `Types that end in \`Connection\` must be an object type as per the relay spec. \`${node.name.value}\` is not an object type.`, 13 | [node] 14 | ) 15 | ); 16 | } 17 | }; 18 | 19 | return { 20 | ScalarTypeDefinition: ensureNameDoesNotEndWithConnection, 21 | InterfaceTypeDefinition: ensureNameDoesNotEndWithConnection, 22 | UnionTypeDefinition: ensureNameDoesNotEndWithConnection, 23 | EnumTypeDefinition: ensureNameDoesNotEndWithConnection, 24 | InputObjectTypeDefinition: ensureNameDoesNotEndWithConnection, 25 | ObjectTypeDefinition(node) { 26 | const typeName = node.name.value; 27 | if (!typeName.endsWith('Connection')) { 28 | return; 29 | } 30 | const fieldNames = node.fields.map((field) => field.name.value); 31 | const missingFields = MANDATORY_FIELDS.filter( 32 | (requiredField) => fieldNames.indexOf(requiredField) === -1 33 | ); 34 | 35 | if (missingFields.length) { 36 | context.reportError( 37 | new ValidationError( 38 | 'relay-connection-types-spec', 39 | `Connection \`${typeName}\` is missing the following field${ 40 | missingFields.length > 1 ? 's' : '' 41 | }: ${missingFields.join(', ')}.`, 42 | [node] 43 | ) 44 | ); 45 | return; 46 | } 47 | 48 | const edgesField = node.fields.find( 49 | (field) => field.name.value == 'edges' 50 | ); 51 | var edgesFieldType = edgesField.type; 52 | 53 | if (edgesFieldType.kind == 'NonNullType') { 54 | edgesFieldType = edgesFieldType.type; 55 | } 56 | 57 | if (edgesFieldType.kind != 'ListType') { 58 | context.reportError( 59 | new ValidationError( 60 | 'relay-connection-types-spec', 61 | `The \`${typeName}.edges\` field must return a list of edges not \`${print( 62 | edgesFieldType 63 | )}\`.`, 64 | [node] 65 | ) 66 | ); 67 | } 68 | 69 | const pageInfoField = node.fields.find( 70 | (field) => field.name.value == 'pageInfo' 71 | ); 72 | const printedPageInfoFieldType = print(pageInfoField.type); 73 | 74 | if (printedPageInfoFieldType != 'PageInfo!') { 75 | context.reportError( 76 | new ValidationError( 77 | 'relay-connection-types-spec', 78 | `The \`${typeName}.pageInfo\` field must return a non-null \`PageInfo\` object not \`${printedPageInfoFieldType}\``, 79 | [node] 80 | ) 81 | ); 82 | } 83 | }, 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /test/rules/type_fields_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { TypeFieldsSortedAlphabetically } from '../../src/rules/type_fields_sorted_alphabetically'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('TypeFieldsSortedAlphabetically rule', () => { 5 | describe('when sortOrder is alphabetical', () => { 6 | it('catches enums that are not sorted alphabetically', () => { 7 | expectFailsRule( 8 | TypeFieldsSortedAlphabetically, 9 | ` 10 | type Stage { 11 | foo: String 12 | Foo: String 13 | bar: String 14 | } 15 | `, 16 | [ 17 | { 18 | message: 19 | 'The fields of object type `Stage` should be sorted in alphabetical order. Expected sorting: Foo, bar, foo', 20 | locations: [{ line: 2, column: 9 }], 21 | }, 22 | ], 23 | { 24 | rulesOptions: { 25 | 'type-fields-sorted-alphabetically': { 26 | sortOrder: 'alphabetical', 27 | }, 28 | }, 29 | } 30 | ); 31 | }); 32 | 33 | it('allows enums that are sorted alphabetically ', () => { 34 | expectPassesRule( 35 | TypeFieldsSortedAlphabetically, 36 | ` 37 | type Stage { 38 | Foo: String 39 | bar: String 40 | foo: String 41 | } 42 | `, 43 | { 44 | rulesOptions: { 45 | 'type-fields-sorted-alphabetically': { 46 | sortOrder: 'alphabetical', 47 | }, 48 | }, 49 | } 50 | ); 51 | }); 52 | }); 53 | 54 | describe('when sortOrder is lexicographical', () => { 55 | it('catches enums that are not sorted lexicographically', () => { 56 | expectFailsRule( 57 | TypeFieldsSortedAlphabetically, 58 | ` 59 | type Stage { 60 | foo: String 61 | Foo: String 62 | bar: String 63 | } 64 | `, 65 | [ 66 | { 67 | message: 68 | 'The fields of object type `Stage` should be sorted in lexicographical order. Expected sorting: bar, foo, Foo', 69 | locations: [{ line: 2, column: 9 }], 70 | }, 71 | ], 72 | { 73 | rulesOptions: { 74 | 'type-fields-sorted-alphabetically': { 75 | sortOrder: 'lexicographical', 76 | }, 77 | }, 78 | } 79 | ); 80 | }); 81 | 82 | it('allows enums that are sorted lexicographically ', () => { 83 | expectPassesRule( 84 | TypeFieldsSortedAlphabetically, 85 | ` 86 | type Stage { 87 | bar: String 88 | foo: String 89 | Foo: String 90 | } 91 | `, 92 | { 93 | rulesOptions: { 94 | 'type-fields-sorted-alphabetically': { 95 | sortOrder: 'lexicographical', 96 | }, 97 | }, 98 | } 99 | ); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/rules/input_object_fields_sorted_alphabetically.js: -------------------------------------------------------------------------------- 1 | import { InputObjectFieldsSortedAlphabetically } from '../../src/rules/input_object_fields_sorted_alphabetically'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('InputObjectFieldsSortedAlphabetically rule', () => { 5 | describe('when sortOrder is alphabetical', () => { 6 | it('catches enums that are not sorted alphabetically', () => { 7 | expectFailsRule( 8 | InputObjectFieldsSortedAlphabetically, 9 | ` 10 | input Stage { 11 | foo: String 12 | bar: String 13 | Bar: String 14 | } 15 | `, 16 | [ 17 | { 18 | message: 19 | 'The fields of input type `Stage` should be sorted in alphabetical order. Expected sorting: Bar, bar, foo', 20 | locations: [{ line: 2, column: 9 }], 21 | }, 22 | ], 23 | { 24 | rulesOptions: { 25 | 'input-object-fields-sorted-alphabetically': { 26 | sortOrder: 'alphabetical', 27 | }, 28 | }, 29 | } 30 | ); 31 | }); 32 | 33 | it('allows enums that are sorted alphabetically ', () => { 34 | expectPassesRule( 35 | InputObjectFieldsSortedAlphabetically, 36 | ` 37 | input Stage { 38 | Bar: String 39 | bar: String 40 | foo: String 41 | } 42 | `, 43 | { 44 | rulesOptions: { 45 | 'input-object-fields-sorted-alphabetically': { 46 | sortOrder: 'alphabetical', 47 | }, 48 | }, 49 | } 50 | ); 51 | }); 52 | }); 53 | 54 | describe('when sortOrder is lexicographical', () => { 55 | it('catches enums that are not sorted lexicographically', () => { 56 | expectFailsRule( 57 | InputObjectFieldsSortedAlphabetically, 58 | ` 59 | input Stage { 60 | foo: String 61 | bar: String 62 | Bar: String 63 | } 64 | `, 65 | [ 66 | { 67 | message: 68 | 'The fields of input type `Stage` should be sorted in lexicographical order. Expected sorting: bar, Bar, foo', 69 | locations: [{ line: 2, column: 9 }], 70 | }, 71 | ], 72 | { 73 | rulesOptions: { 74 | 'input-object-fields-sorted-alphabetically': { 75 | sortOrder: 'lexicographical', 76 | }, 77 | }, 78 | } 79 | ); 80 | }); 81 | 82 | it('allows enums that are sorted lexicographically ', () => { 83 | expectPassesRule( 84 | InputObjectFieldsSortedAlphabetically, 85 | ` 86 | input Stage { 87 | bar: String 88 | Bar: String 89 | foo: String 90 | } 91 | `, 92 | { 93 | rulesOptions: { 94 | 'input-object-fields-sorted-alphabetically': { 95 | sortOrder: 'lexicographical', 96 | }, 97 | }, 98 | } 99 | ); 100 | }); 101 | }); 102 | }); 103 | -------------------------------------------------------------------------------- /test/rules/relay_page_info_spec.js: -------------------------------------------------------------------------------- 1 | import { RelayPageInfoSpec } from '../../src/rules/relay_page_info_spec'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('RelayPageInfoSpec rule', () => { 5 | it('catches missing PageInfo type', () => { 6 | expectFailsRule(RelayPageInfoSpec, '', [ 7 | { 8 | locations: [ 9 | { 10 | column: 1, 11 | line: 1, 12 | }, 13 | ], 14 | message: 'A `PageInfo` object type is required as per the Relay spec.', 15 | }, 16 | ]); 17 | }); 18 | 19 | it('catches missing PageInfo.hasPreviousPage field', () => { 20 | expectFailsRule( 21 | RelayPageInfoSpec, 22 | ` 23 | type PageInfo { 24 | hasNextPage: Boolean! 25 | } 26 | `, 27 | [ 28 | { 29 | locations: [ 30 | { 31 | column: 7, 32 | line: 2, 33 | }, 34 | ], 35 | message: 36 | 'The `PageInfo` object type must have a `hasPreviousPage` field that returns a non-null Boolean as per the Relay spec.', 37 | }, 38 | ] 39 | ); 40 | }); 41 | 42 | it('catches invalid PageInfo.hasPreviousPage field', () => { 43 | expectFailsRule( 44 | RelayPageInfoSpec, 45 | ` 46 | type PageInfo { 47 | hasNextPage: Boolean! 48 | hasPreviousPage: String! 49 | } 50 | `, 51 | [ 52 | { 53 | locations: [ 54 | { 55 | column: 9, 56 | line: 4, 57 | }, 58 | ], 59 | message: 60 | 'The `PageInfo` object type must have a `hasPreviousPage` field that returns a non-null Boolean as per the Relay spec.', 61 | }, 62 | ] 63 | ); 64 | }); 65 | 66 | it('catches missing PageInfo.hasNextPage field', () => { 67 | expectFailsRule( 68 | RelayPageInfoSpec, 69 | ` 70 | type PageInfo { 71 | hasPreviousPage: Boolean! 72 | } 73 | `, 74 | [ 75 | { 76 | locations: [ 77 | { 78 | column: 7, 79 | line: 2, 80 | }, 81 | ], 82 | message: 83 | 'The `PageInfo` object type must have a `hasNextPage` field that returns a non-null Boolean as per the Relay spec.', 84 | }, 85 | ] 86 | ); 87 | }); 88 | 89 | it('catches invalid PageInfo.hasNextPage field', () => { 90 | expectFailsRule( 91 | RelayPageInfoSpec, 92 | ` 93 | type PageInfo { 94 | hasNextPage: String! 95 | hasPreviousPage: Boolean! 96 | } 97 | `, 98 | [ 99 | { 100 | locations: [ 101 | { 102 | column: 9, 103 | line: 3, 104 | }, 105 | ], 106 | message: 107 | 'The `PageInfo` object type must have a `hasNextPage` field that returns a non-null Boolean as per the Relay spec.', 108 | }, 109 | ] 110 | ); 111 | }); 112 | 113 | it('accepts proper definition', () => { 114 | expectPassesRule( 115 | RelayPageInfoSpec, 116 | ` 117 | type PageInfo { 118 | hasNextPage: Boolean! 119 | hasPreviousPage: Boolean! 120 | } 121 | ` 122 | ); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at cmallette@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /test/inline_configuration.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { parse } from 'graphql'; 3 | import { extractInlineConfigs } from '../src/inline_configuration'; 4 | 5 | describe('extractInlineConfigs', () => { 6 | it('removes Comment tokens that are inline configs', () => { 7 | const ast = parse(` 8 | # lint-disable types-have-descriptions 9 | type Query { 10 | viewer: User! 11 | } 12 | 13 | # lint-enable types-have-descriptions 14 | # User description 15 | type User { 16 | email: String! # lint-disable-line fields-have-descriptions 17 | } 18 | `); 19 | 20 | const previousTokens = astToTokens(ast); 21 | extractInlineConfigs(ast); 22 | const afterTokens = astToTokens(ast); 23 | 24 | assert.equal(3, previousTokens.filter(inlineConfigurationToken).length); 25 | assert.equal(0, afterTokens.filter(inlineConfigurationToken).length); 26 | 27 | assert.equal('', afterTokens[0].kind); 28 | assert.equal('type', afterTokens[0].next.value); 29 | }); 30 | 31 | it('extracts lint-enable configuration', () => { 32 | const ast = parse(` 33 | type Query { 34 | viewer: User! 35 | } 36 | 37 | # lint-enable types-have-descriptions 38 | # User description 39 | type User { 40 | # lint-enable fields-have-descriptions, types-have-descriptions 41 | email: String! 42 | } 43 | `); 44 | 45 | const configs = extractInlineConfigs(ast); 46 | 47 | assert.deepEqual( 48 | [ 49 | { command: 'enable', rules: ['types-have-descriptions'], line: 6 }, 50 | { 51 | command: 'enable', 52 | rules: ['fields-have-descriptions', 'types-have-descriptions'], 53 | line: 9, 54 | }, 55 | ], 56 | configs 57 | ); 58 | }); 59 | 60 | it('extracts lint-disable configuration', () => { 61 | const ast = parse(` 62 | type Query { 63 | viewer: User! 64 | } 65 | 66 | # lint-disable types-have-descriptions, another-rule 67 | # User description 68 | type User { 69 | # lint-disable fields-have-descriptions 70 | email: String! 71 | } 72 | `); 73 | 74 | const configs = extractInlineConfigs(ast); 75 | 76 | assert.deepEqual( 77 | [ 78 | { 79 | command: 'disable', 80 | rules: ['types-have-descriptions', 'another-rule'], 81 | line: 6, 82 | }, 83 | { command: 'disable', rules: ['fields-have-descriptions'], line: 9 }, 84 | ], 85 | configs 86 | ); 87 | }); 88 | 89 | it('extracts lint-disable-line configuration', () => { 90 | const ast = parse(` 91 | type Query { 92 | viewer: User! 93 | } 94 | 95 | # User description 96 | type User { # lint-disable-line types-have-descriptions, another-rule 97 | email: String! # lint-disable-line fields-have-descriptions 98 | } 99 | `); 100 | 101 | const configs = extractInlineConfigs(ast); 102 | 103 | assert.deepEqual( 104 | [ 105 | { 106 | command: 'disable-line', 107 | rules: ['types-have-descriptions', 'another-rule'], 108 | line: 7, 109 | }, 110 | { 111 | command: 'disable-line', 112 | rules: ['fields-have-descriptions'], 113 | line: 8, 114 | }, 115 | ], 116 | configs 117 | ); 118 | }); 119 | }); 120 | 121 | function astToTokens(ast) { 122 | var tokens = []; 123 | var token = ast.loc.startToken; 124 | 125 | while (token) { 126 | tokens.push(token); 127 | token = token.next; 128 | } 129 | 130 | return tokens; 131 | } 132 | 133 | function inlineConfigurationToken(token) { 134 | return token.kind == 'Comment' && token.value.startsWith(' lint-'); 135 | } 136 | -------------------------------------------------------------------------------- /test/config/package_json/test.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { readFileSync } from 'fs'; 3 | import assert from 'assert'; 4 | import { Configuration } from '../../../src/configuration'; 5 | import { loadSchema, emptySchema } from '../../../src/schema'; 6 | import { loadOptionsFromConfigDir } from '../../../src/options'; 7 | import { temporaryConfigDirectory } from '../../helpers'; 8 | 9 | describe('Config', () => { 10 | describe('getRules', () => { 11 | it('pulls rule config from the package.json file', () => { 12 | const options = loadOptionsFromConfigDir( 13 | temporaryConfigDirectory({ 14 | rules: ['enum-values-sorted-alphabetically'], 15 | }) 16 | ); 17 | const configuration = new Configuration(emptySchema, options); 18 | 19 | const rules = configuration.getRules(); 20 | 21 | assert.equal(1, rules.length); 22 | assert.equal( 23 | 1, 24 | rules.filter((rule) => { 25 | return rule.name == 'EnumValuesSortedAlphabetically'; 26 | }).length 27 | ); 28 | }); 29 | }); 30 | 31 | describe('getRulesOptions', () => { 32 | it('pulls rule config from the package.json file', () => { 33 | const options = loadOptionsFromConfigDir( 34 | temporaryConfigDirectory({ 35 | rulesOptions: { 36 | 'enum-values-sorted-alphabetically': { 37 | sortOrder: 'alphabetical', 38 | }, 39 | }, 40 | }) 41 | ); 42 | const configuration = new Configuration(emptySchema, options); 43 | const rulesOptions = configuration.getRulesOptions(); 44 | 45 | assert.equal(1, Object.entries(rulesOptions).length); 46 | assert.deepEqual(rulesOptions, { 47 | 'enum-values-sorted-alphabetically': { sortOrder: 'alphabetical' }, 48 | }); 49 | }); 50 | }); 51 | 52 | describe('getIgnoreList', () => { 53 | it('pulls ignore list from the package.json file', () => { 54 | const options = loadOptionsFromConfigDir( 55 | temporaryConfigDirectory({ 56 | ignore: { 57 | 'fields-have-descriptions': [ 58 | 'Obvious', 59 | 'Query.obvious', 60 | 'Query.something.obvious', 61 | ], 62 | }, 63 | }) 64 | ); 65 | const configuration = new Configuration(emptySchema, options); 66 | 67 | const ignoreList = configuration.getIgnoreList(); 68 | 69 | assert.deepEqual(ignoreList, { 70 | 'fields-have-descriptions': [ 71 | 'Obvious', 72 | 'Query.obvious', 73 | 'Query.something.obvious', 74 | ], 75 | }); 76 | }); 77 | 78 | it('defaults to {} when unspecified', () => { 79 | const options = loadOptionsFromConfigDir(temporaryConfigDirectory({})); 80 | const configuration = new Configuration(emptySchema, options); 81 | 82 | const ignoreList = configuration.getIgnoreList(); 83 | 84 | assert.deepEqual(ignoreList, {}); 85 | }); 86 | }); 87 | 88 | describe('customRulePaths', () => { 89 | it('pulls customRulePaths from package.json', () => { 90 | const options = loadOptionsFromConfigDir( 91 | temporaryConfigDirectory({ 92 | rules: ['SomeRule'], 93 | customRulePaths: [ 94 | // we provide the full path to the helper 95 | path.join(__dirname, '../../fixtures/custom_rules/*.js'), 96 | ], 97 | }) 98 | ); 99 | const configuration = new Configuration(emptySchema, options); 100 | 101 | const rules = configuration.getRules(); 102 | 103 | assert.equal(rules.filter(({ name }) => name === 'SomeRule').length, 1); 104 | }); 105 | }); 106 | 107 | describe('schemaPaths', () => { 108 | it('pulls schemaPaths from package.json when configDirectory is provided', async () => { 109 | const fixturePath = path.join( 110 | __dirname, 111 | '/../../fixtures/schema.graphql' 112 | ); 113 | const schema = await loadSchema({ schemaPaths: [fixturePath] }); 114 | const configuration = new Configuration(schema, { 115 | configDirectory: temporaryConfigDirectory({ 116 | rules: ['enum-values-sorted-alphabetically'], 117 | }), 118 | }); 119 | assert.equal( 120 | configuration.getSchema(), 121 | readFileSync(fixturePath).toString('utf8') 122 | ); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /src/rules/relay_connection_arguments_spec.js: -------------------------------------------------------------------------------- 1 | import { ValidationError } from '../validation_error'; 2 | 3 | function unwrapType(type) { 4 | if (type.kind == 'NonNullType') { 5 | return type.type; 6 | } else { 7 | return type; 8 | } 9 | } 10 | 11 | export function RelayConnectionArgumentsSpec(context) { 12 | return { 13 | FieldDefinition(node) { 14 | const fieldType = unwrapType(node.type); 15 | if ( 16 | fieldType.kind != 'NamedType' || 17 | !fieldType.name.value.endsWith('Connection') 18 | ) { 19 | return; 20 | } 21 | 22 | const firstArgument = node.arguments.find((argument) => { 23 | return argument.name.value == 'first'; 24 | }); 25 | const afterArgument = node.arguments.find((argument) => { 26 | return argument.name.value == 'after'; 27 | }); 28 | const hasForwardPagination = firstArgument && afterArgument; 29 | 30 | const lastArgument = node.arguments.find((argument) => { 31 | return argument.name.value == 'last'; 32 | }); 33 | const beforeArgument = node.arguments.find((argument) => { 34 | return argument.name.value == 'before'; 35 | }); 36 | const hasBackwardPagination = lastArgument && beforeArgument; 37 | 38 | if (!hasForwardPagination && !hasBackwardPagination) { 39 | return context.reportError( 40 | new ValidationError( 41 | 'relay-connection-arguments-spec', 42 | 'A field that returns a Connection Type must include forward pagination arguments (`first` and `after`), backward pagination arguments (`last` and `before`), or both as per the Relay spec.', 43 | [node] 44 | ) 45 | ); 46 | } 47 | 48 | if (firstArgument) { 49 | if (hasBackwardPagination) { 50 | // first and last must be nullable if both forward and backward pagination are supported. 51 | if ( 52 | firstArgument.type.kind != 'NamedType' || 53 | firstArgument.type.name.value != 'Int' 54 | ) { 55 | return context.reportError( 56 | new ValidationError( 57 | 'relay-connection-arguments-spec', 58 | 'Fields that support forward and backward pagination must include a `first` argument that takes a nullable non-negative integer as per the Relay spec.', 59 | [firstArgument] 60 | ) 61 | ); 62 | } 63 | } else { 64 | // first can be non-nullable if only forward pagination is supported. 65 | const type = unwrapType(firstArgument.type); 66 | if (type.kind != 'NamedType' || type.name.value != 'Int') { 67 | return context.reportError( 68 | new ValidationError( 69 | 'relay-connection-arguments-spec', 70 | 'Fields that support forward pagination must include a `first` argument that takes a non-negative integer as per the Relay spec.', 71 | [firstArgument] 72 | ) 73 | ); 74 | } 75 | } 76 | } 77 | 78 | if (lastArgument) { 79 | if (hasForwardPagination) { 80 | // first and last must be nullable if both forward and backward pagination are supported. 81 | if ( 82 | lastArgument.type.kind != 'NamedType' || 83 | lastArgument.type.name.value != 'Int' 84 | ) { 85 | return context.reportError( 86 | new ValidationError( 87 | 'relay-connection-arguments-spec', 88 | 'Fields that support forward and backward pagination must include a `last` argument that takes a nullable non-negative integer as per the Relay spec.', 89 | [lastArgument] 90 | ) 91 | ); 92 | } 93 | } else { 94 | // last can be non-nullable if only backward pagination is supported. 95 | const type = unwrapType(lastArgument.type); 96 | if (type.kind != 'NamedType' || type.name.value != 'Int') { 97 | return context.reportError( 98 | new ValidationError( 99 | 'relay-connection-arguments-spec', 100 | 'Fields that support backward pagination must include a `last` argument that takes a non-negative integer as per the Relay spec.', 101 | [lastArgument] 102 | ) 103 | ); 104 | } 105 | } 106 | } 107 | }, 108 | }; 109 | } 110 | -------------------------------------------------------------------------------- /test/rules/types_have_descriptions.js: -------------------------------------------------------------------------------- 1 | import { version } from 'graphql'; 2 | import { TypesHaveDescriptions } from '../../src/rules/types_have_descriptions'; 3 | import { 4 | expectFailsRule, 5 | expectPassesRule, 6 | expectPassesRuleWithConfiguration, 7 | } from '../assertions'; 8 | 9 | const itPotentially = version.startsWith('15.') ? it : it.skip; 10 | 11 | describe('TypesHaveDescriptions rule', () => { 12 | it('catches enum types that have no description', () => { 13 | expectFailsRule( 14 | TypesHaveDescriptions, 15 | ` 16 | "A" 17 | enum A { 18 | A 19 | } 20 | 21 | enum STATUS { 22 | DRAFT 23 | PUBLISHED 24 | HIDDEN 25 | } 26 | `, 27 | [ 28 | { 29 | message: 'The enum type `STATUS` is missing a description.', 30 | locations: [{ line: 7, column: 7 }], 31 | }, 32 | ] 33 | ); 34 | }); 35 | 36 | it('catches scalar types that have no description', () => { 37 | expectFailsRule( 38 | TypesHaveDescriptions, 39 | ` 40 | "A" 41 | scalar A 42 | 43 | scalar DateTime 44 | `, 45 | [ 46 | { 47 | message: 'The scalar type `DateTime` is missing a description.', 48 | locations: [{ line: 5, column: 7 }], 49 | }, 50 | ] 51 | ); 52 | }); 53 | 54 | it('catches object types that have no description', () => { 55 | expectFailsRule( 56 | TypesHaveDescriptions, 57 | ` 58 | type A { 59 | a: String 60 | } 61 | 62 | "B" 63 | type B { 64 | b: String 65 | } 66 | `, 67 | [ 68 | { 69 | message: 'The object type `A` is missing a description.', 70 | locations: [{ line: 2, column: 7 }], 71 | }, 72 | ] 73 | ); 74 | }); 75 | 76 | it('catches input types that have no description', () => { 77 | expectFailsRule( 78 | TypesHaveDescriptions, 79 | ` 80 | input AddStar { 81 | id: ID! 82 | } 83 | 84 | "RemoveStar" 85 | input RemoveStar { 86 | id: ID! 87 | } 88 | `, 89 | [ 90 | { 91 | message: 'The input object type `AddStar` is missing a description.', 92 | locations: [{ line: 2, column: 7 }], 93 | }, 94 | ] 95 | ); 96 | }); 97 | 98 | it('catches interface types that have no description', () => { 99 | expectFailsRule( 100 | TypesHaveDescriptions, 101 | ` 102 | "B" 103 | interface B { 104 | B: String 105 | } 106 | 107 | interface A { 108 | a: String 109 | } 110 | `, 111 | [ 112 | { 113 | message: 'The interface type `A` is missing a description.', 114 | locations: [{ line: 7, column: 7 }], 115 | }, 116 | ] 117 | ); 118 | }); 119 | 120 | it('catches union types that have no description', () => { 121 | expectFailsRule( 122 | TypesHaveDescriptions, 123 | ` 124 | "A" 125 | type A { 126 | a: String 127 | } 128 | 129 | "B" 130 | type B { 131 | b: String 132 | } 133 | 134 | union AB = A | B 135 | 136 | "BA" 137 | union BA = B | A 138 | `, 139 | [ 140 | { 141 | message: 'The union type `AB` is missing a description.', 142 | locations: [{ line: 12, column: 7 }], 143 | }, 144 | ] 145 | ); 146 | }); 147 | 148 | it('ignores type extensions', () => { 149 | expectPassesRule( 150 | TypesHaveDescriptions, 151 | ` 152 | extend type Query { 153 | b: String 154 | } 155 | 156 | "Interface" 157 | interface Vehicle { 158 | make: String! 159 | } 160 | 161 | extend interface Vehicle { 162 | something: String! 163 | } 164 | ` 165 | ); 166 | }); 167 | 168 | itPotentially( 169 | 'gets descriptions correctly with commentDescriptions option', 170 | () => { 171 | expectPassesRuleWithConfiguration( 172 | TypesHaveDescriptions, 173 | ` 174 | # A 175 | scalar A 176 | 177 | # B 178 | type B { 179 | b: String 180 | } 181 | 182 | # C 183 | interface C { 184 | c: String 185 | } 186 | 187 | # D 188 | union D = B 189 | 190 | # E 191 | enum E { 192 | A 193 | } 194 | 195 | # F 196 | input F { 197 | f: String 198 | } 199 | `, 200 | { commentDescriptions: true } 201 | ); 202 | } 203 | ); 204 | }); 205 | -------------------------------------------------------------------------------- /test/find_schema_nodes.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { parse } from 'graphql'; 3 | import { buildASTSchema } from 'graphql/utilities/buildASTSchema'; 4 | import { findSchemaNodes } from '../src/find_schema_nodes'; 5 | 6 | describe('findSchemaNodes', () => { 7 | it('returns relevant AST nodes given object as a scope', () => { 8 | test({ 9 | scopes: ['Query'], 10 | expected: [ 11 | 'FieldDefinition : blem', 12 | 'FieldDefinition : fizz', 13 | 'FieldDefinition : foo', 14 | 'ObjectTypeDefinition : Query', 15 | ], 16 | }); 17 | }); 18 | 19 | it('returns relevant AST nodes given field as a scope', () => { 20 | test({ 21 | scopes: ['Fizz.buzz'], 22 | expected: [ 23 | 'FieldDefinition : buzz', 24 | 'InputValueDefinition : buzzFizz', 25 | 'InputValueDefinition : fizzBuzz', 26 | ], 27 | }); 28 | }); 29 | 30 | it('returns combined list of AST nodes given multiple scopes', () => { 31 | test({ 32 | scopes: ['Query.foo', 'Query.blem'], 33 | expected: ['FieldDefinition : blem', 'FieldDefinition : foo'], 34 | }); 35 | }); 36 | 37 | it('returns relevant AST nodes given parameter as a scope', () => { 38 | test({ 39 | scopes: ['Fizz.buzz.fizzBuzz'], 40 | expected: ['InputValueDefinition : fizzBuzz'], 41 | }); 42 | }); 43 | 44 | it('returns relevant AST nodes given scalar type as a scope', () => { 45 | test({ 46 | scopes: ['Blem'], 47 | expected: ['ScalarTypeDefinition : Blem'], 48 | }); 49 | }); 50 | 51 | it('returns relevant AST nodes given enum as a scope', () => { 52 | test({ 53 | scopes: ['Episode'], 54 | expected: [ 55 | 'EnumTypeDefinition : Episode', 56 | 'EnumValueDefinition : EMPIRE', 57 | 'EnumValueDefinition : JEDI', 58 | 'EnumValueDefinition : NEWHOPE', 59 | ], 60 | }); 61 | }); 62 | 63 | it('returns relevant AST nodes given enum value as a scope', () => { 64 | test({ 65 | scopes: ['Episode.NEWHOPE'], 66 | expected: ['EnumValueDefinition : NEWHOPE'], 67 | }); 68 | }); 69 | 70 | it('returns relevant AST nodes given input type as a scope', () => { 71 | test({ 72 | scopes: ['ReviewInput'], 73 | expected: [ 74 | 'InputObjectTypeDefinition : ReviewInput', 75 | 'InputValueDefinition : commentary', 76 | 'InputValueDefinition : stars', 77 | ], 78 | }); 79 | }); 80 | 81 | it('returns relevant AST nodes given input type field as a scope', () => { 82 | test({ 83 | scopes: ['ReviewInput.commentary'], 84 | expected: ['InputValueDefinition : commentary'], 85 | }); 86 | }); 87 | 88 | it('returns relevant AST nodes given interface as a scope', () => { 89 | test({ 90 | scopes: ['Character'], 91 | expected: [ 92 | 'FieldDefinition : id', 93 | 'FieldDefinition : name', 94 | 'InterfaceTypeDefinition : Character', 95 | ], 96 | }); 97 | }); 98 | 99 | it('returns relevant AST nodes given interface field as a scope', () => { 100 | test({ 101 | scopes: ['Character.name'], 102 | expected: ['FieldDefinition : name'], 103 | }); 104 | }); 105 | 106 | it('returns relevant AST nodes given union as a scope', () => { 107 | test({ 108 | scopes: ['SomeUnion'], 109 | expected: ['UnionTypeDefinition : SomeUnion'], 110 | }); 111 | }); 112 | 113 | const ast = parse(` 114 | type Query { 115 | foo: Foo! 116 | fizz: Fizz 117 | blem: Blem 118 | } 119 | 120 | scalar Blem 121 | 122 | interface Fizz { 123 | buzz(buzzFizz: String!, fizzBuzz: Int): Int 124 | } 125 | 126 | type Foo { 127 | bar(blem: Int!): String 128 | } 129 | 130 | enum Episode { 131 | NEWHOPE 132 | EMPIRE 133 | JEDI 134 | } 135 | 136 | input ReviewInput { 137 | stars: Int! 138 | commentary: String 139 | } 140 | 141 | interface Character { 142 | id: ID! 143 | name: String! 144 | } 145 | 146 | union SomeUnion = Fizz | Foo 147 | `); 148 | 149 | const schema = buildASTSchema(ast, { 150 | commentDescriptions: false, 151 | assumeValidSDL: true, 152 | assumeValid: true, 153 | }); 154 | 155 | // Helper method is used because the output is too noisy 156 | // with all those aux AST nodes like Name and NotNullType 157 | const test = ({ scopes, expected }) => { 158 | assert.deepEqual(findFiltered(scopes), expected); 159 | }; 160 | 161 | const findFiltered = (scopes) => { 162 | const raw = findSchemaNodes(scopes, schema); 163 | const filtered = []; 164 | for (const node of raw) { 165 | if (node.kind.endsWith('Definition')) { 166 | filtered.push(`${node.kind} : ${node.name.value}`); 167 | } 168 | } 169 | filtered.sort(); 170 | return filtered; 171 | }; 172 | }); 173 | -------------------------------------------------------------------------------- /test/rules/defined_types_are_used.js: -------------------------------------------------------------------------------- 1 | import { DefinedTypesAreUsed } from '../../src/rules/defined_types_are_used'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('DefinedTypesAreUsed rule', () => { 5 | it('catches object types that are defined but not used', () => { 6 | expectFailsRule( 7 | DefinedTypesAreUsed, 8 | ` 9 | type A { 10 | a: String 11 | } 12 | `, 13 | [ 14 | { 15 | message: 16 | 'The type `A` is defined in the schema but not used anywhere.', 17 | locations: [{ line: 2, column: 7 }], 18 | }, 19 | ] 20 | ); 21 | }); 22 | 23 | it('catches object types that are extended but not used', () => { 24 | expectFailsRule( 25 | DefinedTypesAreUsed, 26 | ` 27 | type A { 28 | a: String 29 | } 30 | 31 | extend type A { 32 | b: String 33 | } 34 | `, 35 | [ 36 | { 37 | message: 38 | 'The type `A` is defined in the schema but not used anywhere.', 39 | locations: [{ line: 2, column: 7 }], 40 | }, 41 | ] 42 | ); 43 | }); 44 | 45 | it('catches interface types that are defined but not used', () => { 46 | expectFailsRule( 47 | DefinedTypesAreUsed, 48 | ` 49 | interface A { 50 | a: String 51 | } 52 | `, 53 | [ 54 | { 55 | message: 56 | 'The type `A` is defined in the schema but not used anywhere.', 57 | locations: [{ line: 2, column: 7 }], 58 | }, 59 | ] 60 | ); 61 | }); 62 | 63 | it('catches scalar types that are defined but not used', () => { 64 | expectFailsRule( 65 | DefinedTypesAreUsed, 66 | ` 67 | scalar A 68 | `, 69 | [ 70 | { 71 | message: 72 | 'The type `A` is defined in the schema but not used anywhere.', 73 | locations: [{ line: 2, column: 7 }], 74 | }, 75 | ] 76 | ); 77 | }); 78 | 79 | it('catches input types that are defined but not used', () => { 80 | expectFailsRule( 81 | DefinedTypesAreUsed, 82 | ` 83 | input A { 84 | a: String 85 | } 86 | `, 87 | [ 88 | { 89 | message: 90 | 'The type `A` is defined in the schema but not used anywhere.', 91 | locations: [{ line: 2, column: 7 }], 92 | }, 93 | ] 94 | ); 95 | }); 96 | 97 | it('catches union types that are defined but not used', () => { 98 | expectFailsRule( 99 | DefinedTypesAreUsed, 100 | ` 101 | union A = Query 102 | `, 103 | [ 104 | { 105 | message: 106 | 'The type `A` is defined in the schema but not used anywhere.', 107 | locations: [{ line: 2, column: 7 }], 108 | }, 109 | ] 110 | ); 111 | }); 112 | 113 | it('ignores types that are a member of a union', () => { 114 | expectPassesRule( 115 | DefinedTypesAreUsed, 116 | ` 117 | extend type Query { 118 | b: B 119 | } 120 | 121 | type A { 122 | a: String 123 | } 124 | 125 | union B = A | Query 126 | ` 127 | ); 128 | }); 129 | 130 | it('ignores types that implement an interface that is used', () => { 131 | expectPassesRule( 132 | DefinedTypesAreUsed, 133 | ` 134 | extend type Query { 135 | c: Node 136 | } 137 | 138 | interface Node { 139 | id: ID! 140 | } 141 | 142 | type A implements Node { 143 | id: ID! 144 | } 145 | ` 146 | ); 147 | }); 148 | 149 | it('ignores types that are used in field definitions', () => { 150 | expectPassesRule( 151 | DefinedTypesAreUsed, 152 | ` 153 | extend type Query { 154 | B: B 155 | } 156 | 157 | type B { 158 | id: ID! 159 | } 160 | ` 161 | ); 162 | }); 163 | 164 | it('ignores scalar and input types that are used in arguments', () => { 165 | expectPassesRule( 166 | DefinedTypesAreUsed, 167 | ` 168 | extend type Query { 169 | b(date: Date): String 170 | c(c: C): String 171 | } 172 | 173 | scalar Date 174 | 175 | input C { 176 | c: String 177 | } 178 | ` 179 | ); 180 | }); 181 | 182 | it('ignores unreferenced Mutation object type', () => { 183 | expectPassesRule( 184 | DefinedTypesAreUsed, 185 | ` 186 | type Mutation { 187 | a: String 188 | } 189 | ` 190 | ); 191 | }); 192 | 193 | it('ignores unreferenced Subscription object type', () => { 194 | expectPassesRule( 195 | DefinedTypesAreUsed, 196 | ` 197 | type Subscription { 198 | a: String 199 | } 200 | ` 201 | ); 202 | }); 203 | 204 | it('ignores unreferenced Query object type', () => { 205 | expectPassesRule( 206 | DefinedTypesAreUsed, 207 | ` 208 | extend type Query { 209 | c: String 210 | } 211 | ` 212 | ); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/validator.js: -------------------------------------------------------------------------------- 1 | import { parse, version } from 'graphql'; 2 | import { validate } from 'graphql/validation'; 3 | import { buildASTSchema } from 'graphql/utilities/buildASTSchema'; 4 | import { GraphQLError } from 'graphql/error'; 5 | import { validateSDL } from 'graphql/validation/validate'; 6 | import { validateSchema } from 'graphql/type/validate'; 7 | import { extractInlineConfigs } from './inline_configuration'; 8 | import { ValidationError } from './validation_error'; 9 | import { findSchemaNodes } from './find_schema_nodes'; 10 | 11 | export function validateSchemaDefinition(inputSchema, rules, configuration) { 12 | let ast; 13 | 14 | let parseOptions = {}; 15 | if (configuration.getOldImplementsSyntax()) { 16 | parseOptions.allowLegacySDLImplementsInterfaces = true; 17 | } 18 | 19 | try { 20 | ast = parse(inputSchema.definition, parseOptions); 21 | } catch (e) { 22 | if (e instanceof GraphQLError) { 23 | e.ruleName = 'graphql-syntax-error'; 24 | 25 | return [e]; 26 | } else { 27 | throw e; 28 | } 29 | } 30 | 31 | let schemaErrors = validateSDL(ast); 32 | if (schemaErrors.length > 0) { 33 | return sortErrors( 34 | schemaErrors.map((error) => { 35 | return new ValidationError( 36 | 'invalid-graphql-schema', 37 | error.message, 38 | error.nodes 39 | ); 40 | }) 41 | ); 42 | } 43 | 44 | const schema = buildASTSchema(ast, { 45 | commentDescriptions: configuration.getCommentDescriptions(), 46 | assumeValidSDL: true, 47 | assumeValid: true, 48 | }); 49 | 50 | schema.__validationErrors = undefined; 51 | schemaErrors = validateSchema(schema); 52 | if (schemaErrors.length > 0) { 53 | return sortErrors( 54 | schemaErrors.map((error) => { 55 | return new ValidationError( 56 | 'invalid-graphql-schema', 57 | error.message, 58 | error.nodes || ast 59 | ); 60 | }) 61 | ); 62 | } 63 | 64 | const rulesWithConfiguration = rules.map((rule) => { 65 | return ruleWithConfiguration(rule, configuration); 66 | }); 67 | 68 | const isGraphQLVersion15 = version.startsWith('15'); 69 | const validateOptions = { 70 | maxErrors: Number.MAX_SAFE_INTEGER, 71 | }; 72 | 73 | const errors = validate( 74 | schema, 75 | ast, 76 | rulesWithConfiguration, 77 | isGraphQLVersion15 ? undefined : validateOptions, 78 | isGraphQLVersion15 ? validateOptions : undefined 79 | ); 80 | const sortedErrors = sortErrors(errors); 81 | const errorFilters = [ 82 | inlineConfigErrorFilter(ast, inputSchema), 83 | ignoreListErrorFilter(schema, configuration), 84 | ]; 85 | const filteredErrors = sortedErrors.filter((error) => 86 | errorFilters.every((filter) => filter(error)) 87 | ); 88 | 89 | return filteredErrors; 90 | } 91 | 92 | function sortErrors(errors) { 93 | return errors.sort((a, b) => { 94 | return a.locations[0].line - b.locations[0].line; 95 | }); 96 | } 97 | 98 | function ruleWithConfiguration(rule, configuration) { 99 | if (rule.length == 2) { 100 | return function (context) { 101 | return rule(configuration, context); 102 | }; 103 | } else { 104 | return rule; 105 | } 106 | } 107 | 108 | function inlineConfigErrorFilter(ast, inputSchema) { 109 | const inlineConfigs = extractInlineConfigs(ast); 110 | if (inlineConfigs.length === 0) { 111 | return () => true; 112 | } 113 | const schemaSourceMap = inputSchema.sourceMap; 114 | 115 | return (error) => { 116 | let shouldApplyRule = true; 117 | const errorLine = error.locations[0].line; 118 | const errorFilePath = schemaSourceMap.getOriginalPathForLine(errorLine); 119 | 120 | for (const config of inlineConfigs) { 121 | // Skip inline configs that don't modify this error's rule 122 | if (!config.rules.includes(error.ruleName)) { 123 | continue; 124 | } 125 | 126 | // Skip inline configs that aren't in the same source file as the errored line 127 | const configFilePath = schemaSourceMap.getOriginalPathForLine( 128 | config.line 129 | ); 130 | if (configFilePath !== errorFilePath) { 131 | continue; 132 | } 133 | 134 | // When 'disable-line': disable the rule if the error line and the command line match 135 | if (config.command === 'disable-line' && config.line === errorLine) { 136 | shouldApplyRule = false; 137 | break; 138 | } 139 | 140 | // Otherwise, last command wins (expected order by line) 141 | if (config.line < errorLine) { 142 | if (config.command === 'enable') { 143 | shouldApplyRule = true; 144 | } else if (config.command === 'disable') { 145 | shouldApplyRule = false; 146 | } 147 | } 148 | } 149 | 150 | return shouldApplyRule; 151 | }; 152 | } 153 | 154 | function ignoreListErrorFilter(schema, configuration) { 155 | const ignoreList = configuration.getIgnoreList(); 156 | const index = {}; 157 | for (const [rule, scopes] of Object.entries(ignoreList)) { 158 | index[rule] = findSchemaNodes(scopes, schema); 159 | } 160 | 161 | return (error) => { 162 | if (error.ruleName) { 163 | const subjects = index[error.ruleName]; 164 | const ignore = subjects?.has(error.nodes[0]); 165 | return !ignore; 166 | } else { 167 | return true; 168 | } 169 | }; 170 | } 171 | -------------------------------------------------------------------------------- /src/configuration.js: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | 3 | import JSONFormatter from './formatters/json_formatter.js'; 4 | import TextFormatter from './formatters/text_formatter.js'; 5 | import CompactFormatter from './formatters/compact_formatter.js'; 6 | import expandPaths from './util/expandPaths.js'; 7 | 8 | export class Configuration { 9 | /* 10 | options: 11 | - format: (required) `text` | `json` 12 | - rules: [string array] whitelist rules 13 | - rulesOptions: [string to object] configuration options for rules. Example: "rulesOptions": { "enum-values-sorted-alphabetically": { "sortOrder": "lexicographical" } } 14 | - ignore: [string to string array object] ignore list for rules. Example: {"fields-have-descriptions": ["Obvious", "Query.obvious", "Query.something.obvious"]} 15 | - customRulePaths: [string array] path to additional custom rules to be loaded 16 | - commentDescriptions: [boolean] use old way of defining descriptions in GraphQL SDL 17 | - oldImplementsSyntax: [boolean] use old way of defining implemented interfaces in GraphQL SDL 18 | */ 19 | constructor(schema, options = {}) { 20 | const defaultOptions = { 21 | format: 'text', 22 | customRulePaths: [], 23 | commentDescriptions: false, 24 | oldImplementsSyntax: false, 25 | rulesOptions: {}, 26 | ignore: {}, 27 | }; 28 | 29 | this.schema = schema; 30 | this.options = { ...defaultOptions, ...options }; 31 | this.rules = null; 32 | this.builtInRulePaths = path.join(__dirname, 'rules/*.js'); 33 | this.rulePaths = this.options.customRulePaths.concat(this.builtInRulePaths); 34 | } 35 | 36 | getCommentDescriptions() { 37 | return this.options.commentDescriptions; 38 | } 39 | 40 | getOldImplementsSyntax() { 41 | return this.options.oldImplementsSyntax; 42 | } 43 | 44 | getSchema() { 45 | return this.schema.definition; 46 | } 47 | 48 | getSchemaSourceMap() { 49 | return this.schema.sourceMap; 50 | } 51 | 52 | getFormatter() { 53 | switch (this.options.format) { 54 | case 'json': 55 | return JSONFormatter; 56 | case 'text': 57 | return TextFormatter; 58 | case 'compact': 59 | return CompactFormatter; 60 | } 61 | } 62 | 63 | getRules() { 64 | let rules = this.getAllRules(); 65 | let specifiedRules; 66 | if (this.options.rules && this.options.rules.length > 0) { 67 | specifiedRules = this.options.rules.map(toUpperCamelCase); 68 | rules = this.getAllRules().filter((rule) => { 69 | return specifiedRules.indexOf(rule.name) >= 0; 70 | }); 71 | } 72 | 73 | // DEPRECATED - This code should be removed in v1.0.0. 74 | if (this.options.only && this.options.only.length > 0) { 75 | specifiedRules = this.options.only.map(toUpperCamelCase); 76 | rules = this.getAllRules().filter((rule) => { 77 | return specifiedRules.indexOf(rule.name) >= 0; 78 | }); 79 | } 80 | 81 | // DEPRECATED - This code should be removed in v1.0.0. 82 | if (this.options.except && this.options.except.length > 0) { 83 | specifiedRules = this.options.except.map(toUpperCamelCase); 84 | rules = this.getAllRules().filter((rule) => { 85 | return specifiedRules.indexOf(rule.name) == -1; 86 | }); 87 | } 88 | 89 | return rules; 90 | } 91 | 92 | getAllRules() { 93 | if (this.rules !== null) { 94 | return this.rules; 95 | } 96 | 97 | this.rules = this.getRulesFromPaths(this.rulePaths); 98 | 99 | return this.rules; 100 | } 101 | 102 | getRulesFromPaths(rulePaths) { 103 | const expandedPaths = expandPaths(rulePaths); 104 | const rules = new Set([]); 105 | 106 | expandedPaths.map((rulePath) => { 107 | let ruleMap = require(rulePath); 108 | Object.keys(ruleMap).forEach((k) => rules.add(ruleMap[k])); 109 | }); 110 | 111 | return Array.from(rules); 112 | } 113 | 114 | getAllBuiltInRules() { 115 | return this.getRulesFromPaths([this.builtInRulePaths]); 116 | } 117 | 118 | getRulesOptions() { 119 | return this.options.rulesOptions; 120 | } 121 | 122 | getIgnoreList() { 123 | return this.options.ignore; 124 | } 125 | 126 | validate() { 127 | const issues = []; 128 | 129 | let rules; 130 | 131 | try { 132 | rules = this.getAllRules(); 133 | } catch (e) { 134 | if (e.code === 'MODULE_NOT_FOUND') { 135 | issues.push({ 136 | message: `There was an issue loading the specified custom rules: '${ 137 | e.message.split('\n')[0] 138 | }'`, 139 | field: 'custom-rule-paths', 140 | type: 'error', 141 | }); 142 | 143 | rules = this.getAllBuiltInRules(); 144 | } else { 145 | throw e; 146 | } 147 | } 148 | 149 | const ruleNames = rules.map((rule) => rule.name); 150 | 151 | let misConfiguredRuleNames = [] 152 | .concat( 153 | this.options.only || [], 154 | this.options.except || [], 155 | this.options.rules || [] 156 | ) 157 | .map(toUpperCamelCase) 158 | .filter((name) => ruleNames.indexOf(name) == -1); 159 | 160 | if (this.getFormatter() == null) { 161 | issues.push({ 162 | message: `The output format '${this.options.format}' is invalid`, 163 | field: 'format', 164 | type: 'error', 165 | }); 166 | } 167 | 168 | if (misConfiguredRuleNames.length > 0) { 169 | issues.push({ 170 | message: `The following rule(s) are invalid: ${misConfiguredRuleNames.join( 171 | ', ' 172 | )}`, 173 | field: 'rules', 174 | type: 'warning', 175 | }); 176 | } 177 | 178 | return issues; 179 | } 180 | } 181 | 182 | function toUpperCamelCase(string) { 183 | return string 184 | .split('-') 185 | .map((part) => part[0].toUpperCase() + part.slice(1)) 186 | .join(''); 187 | } 188 | -------------------------------------------------------------------------------- /test/rules/relay_connection_types_spec.js: -------------------------------------------------------------------------------- 1 | import { RelayConnectionTypesSpec } from '../../src/rules/relay_connection_types_spec'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('RelayConnectionTypesSpec rule', () => { 5 | it('catches object types that have missing fields', () => { 6 | expectFailsRule( 7 | RelayConnectionTypesSpec, 8 | ` 9 | type BadConnection { 10 | a: String 11 | } 12 | `, 13 | [ 14 | { 15 | message: 16 | 'Connection `BadConnection` is missing the following fields: pageInfo, edges.', 17 | locations: [{ line: 2, column: 7 }], 18 | }, 19 | ] 20 | ); 21 | }); 22 | 23 | it('accepts object types with the correct fields.', () => { 24 | expectPassesRule( 25 | RelayConnectionTypesSpec, 26 | ` 27 | type PageInfo { 28 | a: String 29 | } 30 | 31 | type Edge { 32 | a: String 33 | } 34 | 35 | type BetterConnection { 36 | pageInfo: PageInfo! 37 | edges: [Edge] 38 | } 39 | 40 | type AnotherConnection { 41 | pageInfo: PageInfo! 42 | edges: [Edge]! 43 | } 44 | 45 | type AnotherGoodConnection { 46 | pageInfo: PageInfo! 47 | edges: [Edge!]! 48 | } 49 | 50 | type AgainAnotherConnection { 51 | pageInfo: PageInfo! 52 | edges: [Edge!] 53 | } 54 | ` 55 | ); 56 | }); 57 | 58 | it('catches edges fields that are not lists of edges', () => { 59 | expectFailsRule( 60 | RelayConnectionTypesSpec, 61 | ` 62 | type PageInfo { 63 | a: String 64 | } 65 | 66 | type Edge { 67 | a: String 68 | } 69 | 70 | type BadConnection { 71 | pageInfo: PageInfo! 72 | edges: String 73 | } 74 | 75 | type AnotherBadConnection { 76 | pageInfo: String 77 | edges: [Edge] 78 | } 79 | 80 | type YetAnotherBadConnection { 81 | pageInfo: String! 82 | edges: [Edge] 83 | } 84 | `, 85 | [ 86 | { 87 | message: 88 | 'The `BadConnection.edges` field must return a list of edges not `String`.', 89 | locations: [{ line: 10, column: 7 }], 90 | }, 91 | { 92 | message: 93 | 'The `AnotherBadConnection.pageInfo` field must return a non-null `PageInfo` object not `String`', 94 | locations: [ 95 | { 96 | column: 7, 97 | line: 15, 98 | }, 99 | ], 100 | }, 101 | { 102 | message: 103 | 'The `YetAnotherBadConnection.pageInfo` field must return a non-null `PageInfo` object not `String!`', 104 | locations: [ 105 | { 106 | column: 7, 107 | line: 20, 108 | }, 109 | ], 110 | }, 111 | ] 112 | ); 113 | }); 114 | 115 | it('ignores types that are not objects', () => { 116 | expectPassesRule( 117 | RelayConnectionTypesSpec, 118 | ` 119 | scalar A 120 | 121 | interface B { 122 | a: String 123 | } 124 | 125 | union C = F 126 | 127 | enum D { 128 | SOMETHING 129 | } 130 | 131 | input E { 132 | a: String! 133 | } 134 | 135 | type F { 136 | a: String! 137 | } 138 | ` 139 | ); 140 | }); 141 | 142 | it('catches types that end in Connection but that are not objects', () => { 143 | expectFailsRule( 144 | RelayConnectionTypesSpec, 145 | ` 146 | scalar AConnection 147 | 148 | interface BConnection { 149 | a: String! 150 | } 151 | 152 | type F { 153 | a: String! 154 | } 155 | union CConnection = F 156 | 157 | enum DConnection { 158 | SOMETHING 159 | } 160 | 161 | input EConnection { 162 | a: String! 163 | } 164 | `, 165 | [ 166 | { 167 | locations: [ 168 | { 169 | column: 7, 170 | line: 2, 171 | }, 172 | ], 173 | message: 174 | 'Types that end in `Connection` must be an object type as per the relay spec. `AConnection` is not an object type.', 175 | ruleName: 'relay-connection-types-spec', 176 | }, 177 | { 178 | locations: [ 179 | { 180 | column: 7, 181 | line: 4, 182 | }, 183 | ], 184 | message: 185 | 'Types that end in `Connection` must be an object type as per the relay spec. `BConnection` is not an object type.', 186 | ruleName: 'relay-connection-types-spec', 187 | }, 188 | { 189 | locations: [ 190 | { 191 | column: 7, 192 | line: 11, 193 | }, 194 | ], 195 | message: 196 | 'Types that end in `Connection` must be an object type as per the relay spec. `CConnection` is not an object type.', 197 | ruleName: 'relay-connection-types-spec', 198 | }, 199 | { 200 | locations: [ 201 | { 202 | column: 7, 203 | line: 13, 204 | }, 205 | ], 206 | message: 207 | 'Types that end in `Connection` must be an object type as per the relay spec. `DConnection` is not an object type.', 208 | ruleName: 'relay-connection-types-spec', 209 | }, 210 | { 211 | locations: [ 212 | { 213 | column: 7, 214 | line: 17, 215 | }, 216 | ], 217 | message: 218 | 'Types that end in `Connection` must be an object type as per the relay spec. `EConnection` is not an object type.', 219 | ruleName: 'relay-connection-types-spec', 220 | }, 221 | ] 222 | ); 223 | }); 224 | }); 225 | -------------------------------------------------------------------------------- /test/rules/relay_connection_arguments_spec.js: -------------------------------------------------------------------------------- 1 | import { RelayConnectionArgumentsSpec } from '../../src/rules/relay_connection_arguments_spec'; 2 | import { expectFailsRule, expectPassesRule } from '../assertions'; 3 | 4 | describe('RelayConnectionArgumentsSpec rule', () => { 5 | it('reports connections that do not support forward or backward pagination', () => { 6 | expectFailsRule( 7 | RelayConnectionArgumentsSpec, 8 | ` 9 | extend type Query { 10 | users: UserConnection 11 | } 12 | 13 | type UserConnection { 14 | a: String 15 | } 16 | `, 17 | [ 18 | { 19 | locations: [ 20 | { 21 | column: 9, 22 | line: 3, 23 | }, 24 | ], 25 | message: 26 | 'A field that returns a Connection Type must include forward pagination arguments (`first` and `after`), backward pagination arguments (`last` and `before`), or both as per the Relay spec.', 27 | }, 28 | ] 29 | ); 30 | }); 31 | 32 | it('accepts connection with proper forward pagination arguments', () => { 33 | expectPassesRule( 34 | RelayConnectionArgumentsSpec, 35 | ` 36 | extend type Query { 37 | users(first: Int, after: String): UserConnection 38 | } 39 | 40 | type UserConnection { 41 | a: String 42 | } 43 | ` 44 | ); 45 | }); 46 | 47 | it('accepts connection with proper backward pagination arguments', () => { 48 | expectPassesRule( 49 | RelayConnectionArgumentsSpec, 50 | ` 51 | extend type Query { 52 | users(last: Int, before: String): UserConnection 53 | } 54 | 55 | type UserConnection { 56 | a: String 57 | } 58 | ` 59 | ); 60 | }); 61 | 62 | it('accepts connection with proper forward and backward pagination arguments', () => { 63 | expectPassesRule( 64 | RelayConnectionArgumentsSpec, 65 | ` 66 | extend type Query { 67 | users(first: Int, after: String, last: Int, before: String): UserConnection 68 | } 69 | 70 | type UserConnection { 71 | a: String 72 | } 73 | ` 74 | ); 75 | }); 76 | 77 | it('accepts connection with required first argument if no backward pagination is specified', () => { 78 | expectPassesRule( 79 | RelayConnectionArgumentsSpec, 80 | ` 81 | extend type Query { 82 | users(first: Int!, after: String): UserConnection 83 | } 84 | 85 | type UserConnection { 86 | a: String 87 | } 88 | ` 89 | ); 90 | }); 91 | 92 | it('accepts connection with required last argument if no forward pagination is specified', () => { 93 | expectPassesRule( 94 | RelayConnectionArgumentsSpec, 95 | ` 96 | extend type Query { 97 | users(last: Int!, before: String): UserConnection 98 | } 99 | 100 | type UserConnection { 101 | a: String 102 | } 103 | ` 104 | ); 105 | }); 106 | 107 | it('reports invalid first argument if first is required and backward pagination is specified', () => { 108 | expectFailsRule( 109 | RelayConnectionArgumentsSpec, 110 | ` 111 | extend type Query { 112 | users(first: Int!, after: String, last: Int, before: String): UserConnection 113 | } 114 | 115 | type UserConnection { 116 | a: String 117 | } 118 | `, 119 | [ 120 | { 121 | locations: [ 122 | { 123 | column: 15, 124 | line: 3, 125 | }, 126 | ], 127 | message: 128 | 'Fields that support forward and backward pagination must include a `first` argument that takes a nullable non-negative integer as per the Relay spec.', 129 | }, 130 | ] 131 | ); 132 | }); 133 | 134 | it('reports invalid last argument if last is required and forward pagination is specified', () => { 135 | expectFailsRule( 136 | RelayConnectionArgumentsSpec, 137 | ` 138 | extend type Query { 139 | users(first: Int, after: String, last: Int!, before: String): UserConnection 140 | } 141 | 142 | type UserConnection { 143 | a: String 144 | } 145 | `, 146 | [ 147 | { 148 | locations: [ 149 | { 150 | column: 42, 151 | line: 3, 152 | }, 153 | ], 154 | message: 155 | 'Fields that support forward and backward pagination must include a `last` argument that takes a nullable non-negative integer as per the Relay spec.', 156 | }, 157 | ] 158 | ); 159 | }); 160 | 161 | it('reports invalid first argument', () => { 162 | expectFailsRule( 163 | RelayConnectionArgumentsSpec, 164 | ` 165 | extend type Query { 166 | users(first: String, after: String): UserConnection 167 | } 168 | 169 | type UserConnection { 170 | a: String 171 | } 172 | `, 173 | [ 174 | { 175 | locations: [ 176 | { 177 | column: 15, 178 | line: 3, 179 | }, 180 | ], 181 | message: 182 | 'Fields that support forward pagination must include a `first` argument that takes a non-negative integer as per the Relay spec.', 183 | }, 184 | ] 185 | ); 186 | }); 187 | 188 | it('reports invalid last argument', () => { 189 | expectFailsRule( 190 | RelayConnectionArgumentsSpec, 191 | ` 192 | extend type Query { 193 | users(last: String, before: String): UserConnection 194 | } 195 | 196 | type UserConnection { 197 | a: String 198 | } 199 | `, 200 | [ 201 | { 202 | locations: [ 203 | { 204 | column: 15, 205 | line: 3, 206 | }, 207 | ], 208 | message: 209 | 'Fields that support backward pagination must include a `last` argument that takes a non-negative integer as per the Relay spec.', 210 | }, 211 | ] 212 | ); 213 | }); 214 | }); 215 | -------------------------------------------------------------------------------- /src/runner.js: -------------------------------------------------------------------------------- 1 | import { validateSchemaDefinition } from './validator.js'; 2 | import { version } from '../package.json'; 3 | import { Command } from 'commander'; 4 | import { Configuration } from './configuration.js'; 5 | import { loadSchema } from './schema.js'; 6 | import { loadOptionsFromConfigDir } from './options.js'; 7 | import figures from './figures'; 8 | import chalk from 'chalk'; 9 | 10 | export async function run(stdout, stdin, stderr, argv) { 11 | const commander = new Command() 12 | .usage('[options] [schema.graphql ...]') 13 | .option( 14 | '-r, --rules ', 15 | 'only the rules specified will be used to validate the schema. Example: fields-have-descriptions,types-have-descriptions' 16 | ) 17 | .option( 18 | '-o, --rules-options ', 19 | 'configure the specified rules with the passed in configuration options. example: {"enum-values-sorted-alphabetically":{"sortOrder":"lexicographical"}}' 20 | ) 21 | .option( 22 | '-i, --ignore ', 23 | "ignore errors for specific schema members, example: {'fields-have-descriptions':['Obvious','Query.obvious','Query.something.obvious']}" 24 | ) 25 | .option( 26 | '-f, --format ', 27 | 'choose the output format of the report. Possible values: json, text, compact' 28 | ) 29 | .option( 30 | '-s, --stdin', 31 | 'schema definition will be read from STDIN instead of specified file.' 32 | ) 33 | .option( 34 | '-c, --config-directory ', 35 | 'path to begin searching for config files.' 36 | ) 37 | .option( 38 | '-p, --custom-rule-paths ', 39 | 'path to additional custom rules to be loaded. Example: rules/*.js' 40 | ) 41 | .option( 42 | '--comment-descriptions', 43 | 'use old way of defining descriptions in GraphQL SDL' 44 | ) 45 | .option( 46 | '--old-implements-syntax', 47 | 'use old way of defining implemented interfaces in GraphQL SDL' 48 | ) 49 | // DEPRECATED - This code should be removed in v1.0.0. 50 | .option( 51 | '-o, --only ', 52 | 'This option is DEPRECATED. Use `--rules` instead.' 53 | ) 54 | // DEPRECATED - This code should be removed in v1.0.0. 55 | .option( 56 | '-e, --except ', 57 | 'This option is DEPRECATED. Use `--rules` instead.' 58 | ) 59 | .version(version, '--version') 60 | .parse(argv); 61 | 62 | if (commander.only || commander.except) { 63 | stderr.write( 64 | `${chalk.yellow(figures.warning)} The ${chalk.bold( 65 | '--only' 66 | )} and ${chalk.bold('--except')} command line options ` + 67 | `have been deprecated. They will be removed in ${chalk.bold( 68 | 'v1.0.0' 69 | )}.\n\n` 70 | ); 71 | } 72 | 73 | // TODO Get configs from .graphqlconfig file 74 | 75 | const optionsFromCommandLine = getOptionsFromCommander(commander); 76 | const optionsFromConfig = loadOptionsFromConfigDir( 77 | optionsFromCommandLine.configDirectory 78 | ); 79 | const options = { ...optionsFromConfig, ...optionsFromCommandLine }; 80 | 81 | const schema = await loadSchema(options, stdin); 82 | 83 | if (schema === null) { 84 | console.error('No valid schema input.'); 85 | return 2; 86 | } 87 | 88 | const configuration = new Configuration(schema, options); 89 | 90 | const issues = configuration.validate(); 91 | 92 | issues.map((issue) => { 93 | var prefix; 94 | if (issue.type == 'error') { 95 | prefix = `${chalk.red(figures.cross)} Error`; 96 | } else { 97 | prefix = `${chalk.yellow(figures.warning)} Warning`; 98 | } 99 | stderr.write( 100 | `${prefix} on ${chalk.bold(issue.field)}: ${issue.message}\n\n` 101 | ); 102 | }); 103 | 104 | if (issues.some((issue) => issue.type == 'error')) { 105 | return 2; 106 | } 107 | 108 | const formatter = configuration.getFormatter(); 109 | const rules = configuration.getRules(); 110 | 111 | const errors = validateSchemaDefinition(schema, rules, configuration); 112 | const groupedErrors = groupErrorsBySchemaFilePath(errors, schema.sourceMap); 113 | 114 | const writeResult = stdout.write(formatter(groupedErrors)); 115 | if (!writeResult) { 116 | await new Promise((resolve) => { 117 | stdout.on('drain', () => resolve()); 118 | }); 119 | } 120 | 121 | return errors.length > 0 ? 1 : 0; 122 | } 123 | 124 | function groupErrorsBySchemaFilePath(errors, schemaSourceMap) { 125 | return errors.reduce((groupedErrors, error) => { 126 | const path = schemaSourceMap.getOriginalPathForLine( 127 | error.locations[0].line 128 | ); 129 | 130 | const offsetForPath = schemaSourceMap.getOffsetForPath(path); 131 | error.locations[0].line = 132 | error.locations[0].line - offsetForPath.startLine + 1; 133 | 134 | groupedErrors[path] = groupedErrors[path] || []; 135 | groupedErrors[path].push(error); 136 | 137 | return groupedErrors; 138 | }, {}); 139 | } 140 | 141 | function getOptionsFromCommander(commander) { 142 | let options = { stdin: commander.stdin }; 143 | 144 | if (commander.configDirectory) { 145 | options.configDirectory = commander.configDirectory; 146 | } 147 | 148 | if (commander.except) { 149 | options.except = commander.except.split(','); 150 | } 151 | 152 | if (commander.format) { 153 | options.format = commander.format; 154 | } 155 | 156 | if (commander.only) { 157 | options.only = commander.only.split(','); 158 | } 159 | 160 | if (commander.rules) { 161 | options.rules = commander.rules.split(','); 162 | } 163 | 164 | if (commander.rulesOptions) { 165 | options.rulesOptions = JSON.parse(commander.rulesOptions); 166 | } 167 | 168 | if (commander.ignore) { 169 | options.ignore = JSON.parse(commander.ignore); 170 | } 171 | 172 | if (commander.customRulePaths) { 173 | options.customRulePaths = commander.customRulePaths.split(','); 174 | } 175 | 176 | if (commander.commentDescriptions) { 177 | options.commentDescriptions = commander.commentDescriptions; 178 | } 179 | 180 | if (commander.oldImplementsSyntax) { 181 | options.oldImplementsSyntax = commander.oldImplementsSyntax; 182 | } 183 | 184 | if (commander.args && commander.args.length) { 185 | options.schemaPaths = commander.args; 186 | } 187 | 188 | return options; 189 | } 190 | -------------------------------------------------------------------------------- /test/validator.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { validateSchemaDefinition } from '../src/validator'; 3 | import { Configuration } from '../src/configuration'; 4 | import { loadSchema } from '../src/schema'; 5 | import { FieldsHaveDescriptions } from '../src/rules/fields_have_descriptions'; 6 | import { GraphQLError } from 'graphql/error'; 7 | import { ValidationError } from '../src/validation_error'; 8 | 9 | describe('validateSchemaDefinition', () => { 10 | it('returns errors sorted by line number', async () => { 11 | const schemaPath = `${__dirname}/fixtures/schema/**/*.graphql`; 12 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 13 | const options = { 14 | ignore: { 'fields-have-descriptions': ['Obvious', 'DontPanic.obvious'] }, 15 | }; 16 | const configuration = new Configuration(schema, options); 17 | 18 | const rules = [FieldsHaveDescriptions, DummyValidator]; 19 | 20 | const errors = validateSchemaDefinition(schema, rules, configuration); 21 | const errorLineNumbers = errors.map((error) => { 22 | return error.locations[0].line; 23 | }); 24 | 25 | assert.equal(10, errors.length); 26 | 27 | assert.deepEqual(errorLineNumbers.sort(), errorLineNumbers); 28 | }); 29 | 30 | it('catches and returns GraphQL syntax errors', async () => { 31 | const schemaPath = `${__dirname}/fixtures/invalid.graphql`; 32 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 33 | const configuration = new Configuration(schema); 34 | 35 | const errors = validateSchemaDefinition(schema, [], configuration); 36 | 37 | assert.equal(1, errors.length); 38 | }); 39 | 40 | it('reports schema with missing query root', async () => { 41 | const schemaPath = `${__dirname}/fixtures/schema.missing-query-root.graphql`; 42 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 43 | const configuration = new Configuration(schema); 44 | 45 | const errors = validateSchemaDefinition(schema, [], configuration); 46 | 47 | assert.equal(1, errors.length); 48 | }); 49 | 50 | it('catches and returns GraphQL schema errors', async () => { 51 | const schemaPath = `${__dirname}/fixtures/invalid-schema.graphql`; 52 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 53 | const configuration = new Configuration(schema); 54 | 55 | const errors = validateSchemaDefinition(schema, [], configuration); 56 | 57 | assert.equal(2, errors.length); 58 | 59 | assert.equal('Unknown type "Node".', errors[0].message); 60 | assert.equal(3, errors[0].locations[0].line); 61 | 62 | assert.equal('Unknown type "Product".', errors[1].message); 63 | assert.equal(7, errors[1].locations[0].line); 64 | }); 65 | 66 | it('handles invalid GraphQL schemas', async () => { 67 | const schemaPath = `${__dirname}/fixtures/invalid-query-root.graphql`; 68 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 69 | const configuration = new Configuration(schema); 70 | 71 | const errors = validateSchemaDefinition(schema, [], configuration); 72 | 73 | assert.equal(1, errors.length); 74 | 75 | assert.equal( 76 | 'Query root type must be Object type, it cannot be Query.', 77 | errors[0].message 78 | ); 79 | assert.equal(1, errors[0].locations[0].line); 80 | }); 81 | 82 | it('passes configuration to rules that require it', async () => { 83 | const schemaPath = `${__dirname}/fixtures/valid.graphql`; 84 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 85 | const configuration = new Configuration(schema); 86 | 87 | const ruleWithConfiguration = (config, context) => { 88 | assert.equal(configuration, config); 89 | assert.equal('ValidationContext', context.constructor.name); 90 | return {}; 91 | }; 92 | 93 | const ruleWithoutConfiguration = (context) => { 94 | assert.equal('ValidationContext', context.constructor.name); 95 | return {}; 96 | }; 97 | 98 | const errors = validateSchemaDefinition( 99 | schema, 100 | [ruleWithConfiguration, ruleWithoutConfiguration], 101 | configuration 102 | ); 103 | 104 | assert.equal(0, errors.length); 105 | }); 106 | 107 | it('can be configured to ignore validation rule failures', async () => { 108 | const schemaPath = `${__dirname}/fixtures/schema/schema.graphql`; 109 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 110 | const options = { 111 | ignore: { 'rule-validator': ['Query.something'] }, 112 | }; 113 | const configuration = new Configuration(schema, options); 114 | 115 | const rules = [FieldRuleValidator]; 116 | 117 | const errors = validateSchemaDefinition(schema, rules, configuration); 118 | 119 | assert.equal(0, errors.length); 120 | }); 121 | 122 | it('can be configured to ignore custom validation rule failures', async () => { 123 | const schemaPath = `${__dirname}/fixtures/schema/schema.graphql`; 124 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 125 | const options = { 126 | ignore: { 'custom-rule-validator': ['Query.something'] }, 127 | }; 128 | const configuration = new Configuration(schema, options); 129 | 130 | const rules = [FieldCustomRuleValidator]; 131 | 132 | const errors = validateSchemaDefinition(schema, rules, configuration); 133 | 134 | assert.equal(0, errors.length); 135 | }); 136 | 137 | it('cannot be configured to ignore syntax errors', async () => { 138 | const schemaPath = `${__dirname}/fixtures/schema/schema.graphql`; 139 | const schema = await loadSchema({ schemaPaths: [schemaPath] }); 140 | const options = { 141 | ignore: { 'rule-validator': ['Query.something'] }, 142 | }; 143 | const configuration = new Configuration(schema, options); 144 | 145 | const rules = [FieldCoreValidator]; 146 | 147 | const errors = validateSchemaDefinition(schema, rules, configuration); 148 | 149 | assert.equal(1, errors.length); 150 | }); 151 | }); 152 | 153 | function DummyValidator(context) { 154 | return { 155 | Document: { 156 | leave: (node) => { 157 | context.reportError(new GraphQLError('Dummy message', [node])); 158 | }, 159 | }, 160 | }; 161 | } 162 | 163 | function FieldCoreValidator(context) { 164 | return { 165 | FieldDefinition(node) { 166 | context.reportError(new GraphQLError('Dummy message', [node])); 167 | }, 168 | }; 169 | } 170 | 171 | function FieldRuleValidator(context) { 172 | return { 173 | FieldDefinition(node) { 174 | context.reportError( 175 | new ValidationError('rule-validator', 'Dummy message', [node]) 176 | ); 177 | }, 178 | }; 179 | } 180 | 181 | class CustomValidationError extends GraphQLError { 182 | constructor(ruleName, message, nodes) { 183 | super(message, nodes); 184 | 185 | this.ruleName = ruleName; 186 | } 187 | } 188 | 189 | function FieldCustomRuleValidator(context) { 190 | return { 191 | FieldDefinition(node) { 192 | context.reportError( 193 | new CustomValidationError('custom-rule-validator', 'Dummy message', [ 194 | node, 195 | ]) 196 | ); 197 | }, 198 | }; 199 | } 200 | -------------------------------------------------------------------------------- /test/configuration.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { Configuration } from '../src/configuration.js'; 3 | import { emptySchema } from '../src/schema.js'; 4 | import JSONFormatter from '../src/formatters/json_formatter.js'; 5 | import TextFormatter from '../src/formatters/text_formatter.js'; 6 | import { openSync, readFileSync } from 'fs'; 7 | import { relative as pathRelative } from 'path'; 8 | 9 | describe('Configuration', () => { 10 | describe('getFormatter', () => { 11 | it('returns text formatter', () => { 12 | const configuration = new Configuration(emptySchema, { format: 'text' }); 13 | assert.equal(configuration.getFormatter(), TextFormatter); 14 | }); 15 | 16 | it('returns json formatter', () => { 17 | const configuration = new Configuration(emptySchema, { format: 'json' }); 18 | assert.equal(configuration.getFormatter(), JSONFormatter); 19 | }); 20 | 21 | it('raises on invalid formatter', () => { 22 | // TODO 23 | }); 24 | }); 25 | 26 | describe('getRules', () => { 27 | it('raises when both --only and --except are specified', () => { 28 | // TODO 29 | }); 30 | 31 | it('returns all rules when --only and --except are not specified', () => { 32 | const configuration = new Configuration(emptySchema); 33 | assert.equal(configuration.getRules(), configuration.getAllRules()); 34 | }); 35 | 36 | it('omits rules that are not specified in --only', () => { 37 | const configuration = new Configuration(emptySchema, { 38 | only: ['fields-have-descriptions', 'types-have-descriptions'], 39 | }); 40 | 41 | const rules = configuration.getRules(); 42 | 43 | assert.equal(rules.length, 2); 44 | assert.equal( 45 | rules[0], 46 | configuration.getAllRules().find(rule => { 47 | return rule.name == 'FieldsHaveDescriptions'; 48 | }) 49 | ); 50 | assert.equal( 51 | rules[1], 52 | configuration.getAllRules().find(rule => { 53 | return rule.name == 'TypesHaveDescriptions'; 54 | }) 55 | ); 56 | }); 57 | 58 | it('omits rules that are specified in --except', () => { 59 | const configuration = new Configuration(emptySchema, { 60 | except: ['fields-have-descriptions', 'types-have-descriptions'], 61 | }); 62 | 63 | const rules = configuration.getRules(); 64 | 65 | assert.equal(rules.length, configuration.getAllRules().length - 2); 66 | assert.equal( 67 | 0, 68 | rules.filter(rule => { 69 | return ( 70 | rule.name == 'FieldsHaveDescriptions' || 71 | rule.name == 'TypesHaveDescriptions' 72 | ); 73 | }).length 74 | ); 75 | }); 76 | 77 | it('omits rules that are not specified in --only (PascalCase)', () => { 78 | const configuration = new Configuration(emptySchema, { 79 | only: ['FieldsHaveDescriptions', 'TypesHaveDescriptions'], 80 | }); 81 | 82 | const rules = configuration.getRules(); 83 | 84 | assert.equal(rules.length, 2); 85 | assert.equal( 86 | rules[0], 87 | configuration.getAllRules().find(rule => { 88 | return rule.name == 'FieldsHaveDescriptions'; 89 | }) 90 | ); 91 | assert.equal( 92 | rules[1], 93 | configuration.getAllRules().find(rule => { 94 | return rule.name == 'TypesHaveDescriptions'; 95 | }) 96 | ); 97 | }); 98 | 99 | it('omits rules that are specified in --except (PascalCase)', () => { 100 | const configuration = new Configuration(emptySchema, { 101 | except: ['FieldsHaveDescriptions', 'TypesHaveDescriptions'], 102 | }); 103 | 104 | const rules = configuration.getRules(); 105 | 106 | assert.equal(rules.length, configuration.getAllRules().length - 2); 107 | assert.equal( 108 | 0, 109 | rules.filter(rule => { 110 | return ( 111 | rule.name == 'FieldsHaveDescriptions' || 112 | rule.name == 'TypesHaveDescriptions' 113 | ); 114 | }).length 115 | ); 116 | }); 117 | 118 | it('dedups duplicate rules', () => { 119 | const configuration = new Configuration(emptySchema, { 120 | customRulePaths: [ 121 | `${__dirname}/fixtures/custom_rules/*.js`, 122 | `${__dirname}/fixtures/custom_rules/type_name_cannot_contain_type.js`, 123 | ], 124 | }); 125 | 126 | const rules = configuration.getRules(); 127 | 128 | assert.equal( 129 | 2, 130 | rules.filter(rule => { 131 | return ( 132 | rule.name == 'EnumNameCannotContainEnum' || 133 | rule.name == 'TypeNameCannotContainType' 134 | ); 135 | }).length 136 | ); 137 | }); 138 | 139 | it('adds custom rules that are specified in --custom-rules-path', () => { 140 | const configuration = new Configuration(emptySchema, { 141 | customRulePaths: [`${__dirname}/fixtures/custom_rules/*.js`], 142 | }); 143 | 144 | const rules = configuration.getRules(); 145 | 146 | assert.equal( 147 | 4, 148 | rules.filter(rule => { 149 | return ( 150 | rule.name == 'SomeRule' || 151 | rule.name == 'AnotherRule' || 152 | rule.name == 'EnumNameCannotContainEnum' || 153 | rule.name == 'TypeNameCannotContainType' 154 | ); 155 | }).length 156 | ); 157 | }); 158 | 159 | it('adds a custom rules that is specified in --custom-rules-path', () => { 160 | const configuration = new Configuration(emptySchema, { 161 | customRulePaths: [ 162 | `${__dirname}/fixtures/custom_rules/type_name_cannot_contain_type.js`, 163 | ], 164 | }); 165 | 166 | const rules = configuration.getRules(); 167 | 168 | assert.equal( 169 | 1, 170 | rules.filter(rule => { 171 | return rule.name == 'TypeNameCannotContainType'; 172 | }).length 173 | ); 174 | }); 175 | }); 176 | 177 | describe('validate', () => { 178 | it('errors when an invalid format is configured', () => { 179 | const configuration = new Configuration(emptySchema, { 180 | format: 'xml', 181 | }); 182 | 183 | const issues = configuration.validate(); 184 | 185 | assert.equal(issues.length, 1); 186 | assert.equal(issues[0].message, "The output format 'xml' is invalid"); 187 | assert.equal(issues[0].field, 'format'); 188 | assert.equal(issues[0].type, 'error'); 189 | }); 190 | 191 | it('warns when an invalid rule is configured', () => { 192 | const configuration = new Configuration(emptySchema, { 193 | except: ['NoRuleOfMine', 'FieldsHaveDescriptions'], 194 | }); 195 | 196 | const issues = configuration.validate(); 197 | 198 | assert.equal(issues.length, 1); 199 | assert.equal( 200 | issues[0].message, 201 | 'The following rule(s) are invalid: NoRuleOfMine' 202 | ); 203 | assert.equal(issues[0].field, 'rules'); 204 | assert.equal(issues[0].type, 'warning'); 205 | }); 206 | 207 | it('errors when invalid custom rule paths is configured', () => { 208 | const invalidPaths = [ 209 | `${__dirname}/fixtures/nonexistent_path`, 210 | `${__dirname}/fixtures/custom_rules/*.js`, 211 | ]; 212 | 213 | const configuration = new Configuration(emptySchema, { 214 | customRulePaths: invalidPaths, 215 | rules: ['fields-have-descriptions', 'types-have-descriptions'], 216 | }); 217 | 218 | const issues = configuration.validate(); 219 | 220 | assert.equal(issues.length, 1); 221 | assert.equal( 222 | issues[0].message, 223 | `There was an issue loading the specified custom rules: 'Cannot find module '${__dirname}/fixtures/nonexistent_path''` 224 | ); 225 | assert.equal(issues[0].field, 'custom-rule-paths'); 226 | assert.equal(issues[0].type, 'error'); 227 | }); 228 | 229 | it('warns and errors when multiple issues arise configured', () => { 230 | const configuration = new Configuration(emptySchema, { 231 | except: ['NoRuleOfMine', 'FieldsHaveDescriptions'], 232 | format: 'xml', 233 | }); 234 | 235 | const issues = configuration.validate(); 236 | 237 | assert.equal(issues.length, 2); 238 | }); 239 | }); 240 | 241 | describe('getCommentDescriptions', () => { 242 | it('defaults to false', () => { 243 | const configuration = new Configuration(emptySchema, {}); 244 | assert.equal(configuration.getCommentDescriptions(), false); 245 | }); 246 | 247 | it('returns specified value', () => { 248 | const configuration = new Configuration(emptySchema, { 249 | commentDescriptions: true, 250 | }); 251 | assert.equal(configuration.getCommentDescriptions(), true); 252 | }); 253 | }); 254 | 255 | describe('getOldImplementsSyntax', () => { 256 | it('defaults to false', () => { 257 | const configuration = new Configuration(emptySchema, {}); 258 | assert.equal(configuration.getOldImplementsSyntax(), false); 259 | }); 260 | 261 | it('returns specified value', () => { 262 | const configuration = new Configuration(emptySchema, { 263 | oldImplementsSyntax: true, 264 | }); 265 | assert.equal(configuration.getOldImplementsSyntax(), true); 266 | }); 267 | }); 268 | }); 269 | -------------------------------------------------------------------------------- /test/runner.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import { version } from 'graphql'; 3 | import { run } from '../src/runner.js'; 4 | import { createReadStream } from 'fs'; 5 | import { Readable } from 'stream'; 6 | import { stripAnsi } from './strip_ansi.js'; 7 | 8 | const itPotentially = version.startsWith('15.') ? it : it.skip; 9 | 10 | describe('Runner', () => { 11 | var stdout; 12 | var mockStdout = { 13 | write: (text) => { 14 | stdout = stdout + text; 15 | }, 16 | on: (_eventName, callback) => { 17 | const delay = Math.random() * (20 - 10) + 10; 18 | setTimeout(callback, delay); 19 | }, 20 | }; 21 | 22 | var stderr; 23 | var mockStderr = { 24 | write: (text) => { 25 | stderr = stderr + text; 26 | }, 27 | on: (_eventName, callback) => { 28 | const delay = Math.random() * (20 - 10) + 10; 29 | setTimeout(callback, delay); 30 | }, 31 | }; 32 | 33 | beforeEach(() => { 34 | stdout = ''; 35 | stderr = ''; 36 | }); 37 | 38 | const fixturePath = `${__dirname}/fixtures/schema.graphql`; 39 | const mockStdin = createReadStream(fixturePath); 40 | 41 | describe('run', async () => { 42 | it('returns exit code 1 when schema has a syntax error', async () => { 43 | const argv = [ 44 | 'node', 45 | 'lib/cli.js', 46 | '--format', 47 | 'json', 48 | `${__dirname}/fixtures/invalid.graphql`, 49 | ]; 50 | 51 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 52 | assert.equal(1, exitCode); 53 | 54 | var errors = JSON.parse(stdout)['errors']; 55 | assert(errors); 56 | assert.equal(1, errors.length); 57 | }); 58 | 59 | it('returns exit code 1 when there are errors', async () => { 60 | const argv = [ 61 | 'node', 62 | 'lib/cli.js', 63 | '--rules', 64 | 'fields-have-descriptions', 65 | fixturePath, 66 | ]; 67 | 68 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 69 | assert.equal(1, exitCode); 70 | }); 71 | 72 | it('validates schema when query root is missing', async () => { 73 | const argv = [ 74 | 'node', 75 | 'lib/cli.js', 76 | `${__dirname}/fixtures/schema.missing-query-root.graphql`, 77 | ]; 78 | 79 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 80 | 81 | const expected = 82 | `${__dirname}/fixtures/schema.missing-query-root.graphql\n` + 83 | '1:1 Query root type must be provided. invalid-graphql-schema\n' + 84 | '\n' + 85 | '✖ 1 error detected\n'; 86 | 87 | assert.equal(expected, stripAnsi(stdout)); 88 | }); 89 | 90 | it('validates schema when ast is invalid', async () => { 91 | const argv = [ 92 | 'node', 93 | 'lib/cli.js', 94 | `${__dirname}/fixtures/invalid-ast.graphql`, 95 | ]; 96 | 97 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 98 | 99 | const expected = 100 | `${__dirname}/fixtures/invalid-ast.graphql\n` + 101 | '9:1 Must provide only one schema definition. invalid-graphql-schema\n' + 102 | '\n' + 103 | '✖ 1 error detected\n'; 104 | 105 | assert.equal(expected, stripAnsi(stdout)); 106 | }); 107 | 108 | it('returns exit code 0 when there are errors', async () => { 109 | const argv = [ 110 | 'node', 111 | 'lib/cli.js', 112 | '--rules', 113 | 'fields-have-descriptions', 114 | `${__dirname}/fixtures/valid.graphql`, 115 | ]; 116 | 117 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 118 | assert.equal(0, exitCode); 119 | }); 120 | 121 | itPotentially( 122 | 'allows setting descriptions using comments in GraphQL SDL', 123 | async () => { 124 | const argv = [ 125 | 'node', 126 | 'lib/cli.js', 127 | '--format', 128 | 'text', 129 | '--comment-descriptions', 130 | '--rules', 131 | 'fields-have-descriptions', 132 | `${__dirname}/fixtures/schema.comment-descriptions.graphql`, 133 | ]; 134 | 135 | await run(mockStdout, mockStdin, mockStderr, argv); 136 | 137 | const expected = 138 | `${__dirname}/fixtures/schema.comment-descriptions.graphql\n` + 139 | '3:3 The field `Query.a` is missing a description. fields-have-descriptions\n' + 140 | '\n' + 141 | '✖ 1 error detected\n'; 142 | 143 | assert.equal(expected, stripAnsi(stdout)); 144 | } 145 | ); 146 | 147 | itPotentially( 148 | 'allows using old `implements` syntax in GraphQL SDL', 149 | async () => { 150 | const argv = [ 151 | 'node', 152 | 'lib/cli.js', 153 | '--format', 154 | 'json', 155 | '--old-implements-syntax', 156 | '--rules', 157 | 'types-have-descriptions', 158 | `${__dirname}/fixtures/schema.old-implements.graphql`, 159 | ]; 160 | 161 | await run(mockStdout, mockStdin, mockStderr, argv); 162 | 163 | assert.deepEqual([], JSON.parse(stdout)['errors']); 164 | } 165 | ); 166 | 167 | it('validates using new `implements` syntax in GraphQL SDL', async () => { 168 | const argv = [ 169 | 'node', 170 | 'lib/cli.js', 171 | '--format', 172 | 'json', 173 | '--rules', 174 | 'types-have-descriptions', 175 | `${__dirname}/fixtures/schema.new-implements.graphql`, 176 | ]; 177 | 178 | await run(mockStdout, mockStdin, mockStderr, argv); 179 | 180 | assert.deepEqual([], JSON.parse(stdout)['errors']); 181 | }); 182 | 183 | it('validates a single schema file and outputs in text', async () => { 184 | const argv = [ 185 | 'node', 186 | 'lib/cli.js', 187 | '--format', 188 | 'text', 189 | '--rules', 190 | 'fields-have-descriptions', 191 | fixturePath, 192 | ]; 193 | 194 | await run(mockStdout, mockStdin, mockStderr, argv); 195 | 196 | const expected = 197 | `${fixturePath}\n` + 198 | '2:3 The field `Query.a` is missing a description. fields-have-descriptions\n' + 199 | '\n' + 200 | '✖ 1 error detected\n'; 201 | 202 | assert.equal(expected, stripAnsi(stdout)); 203 | }); 204 | 205 | it('validates a single schema file by a custom rule and outputs in text', async () => { 206 | const argv = [ 207 | 'node', 208 | 'lib/cli.js', 209 | '--format', 210 | 'text', 211 | '--rules', 212 | 'enum-name-cannot-contain-enum', 213 | '--custom-rule-paths', 214 | `${__dirname}/fixtures/custom_rules/*`, 215 | `${__dirname}/fixtures/animal.graphql`, 216 | ]; 217 | 218 | await run(mockStdout, mockStdin, mockStderr, argv); 219 | 220 | const expected = 221 | `${__dirname}/fixtures/animal.graphql\n` + 222 | "18:3 The enum value `AnimalTypes.CAT_ENUM` cannot include the word 'enum'. enum-name-cannot-contain-enum\n" + 223 | "21:3 The enum value `AnimalTypes.DOG_ENUM` cannot include the word 'enum'. enum-name-cannot-contain-enum\n" + 224 | '\n' + 225 | '✖ 2 errors detected\n'; 226 | 227 | assert.equal(expected, stripAnsi(stdout)); 228 | }); 229 | 230 | it('validates schema passed in via stdin and outputs in json', async () => { 231 | const argv = [ 232 | 'node', 233 | 'lib/cli.js', 234 | '--format', 235 | 'json', 236 | '--rules', 237 | 'fields-have-descriptions', 238 | '--stdin', 239 | ]; 240 | 241 | await run(mockStdout, mockStdin, mockStderr, argv); 242 | 243 | var errors = JSON.parse(stdout)['errors']; 244 | assert(errors); 245 | assert.equal(1, errors.length); 246 | }); 247 | 248 | it('validates a single schema file and outputs in json', async () => { 249 | const argv = [ 250 | 'node', 251 | 'lib/cli.js', 252 | '--format', 253 | 'json', 254 | '--rules', 255 | 'fields-have-descriptions', 256 | fixturePath, 257 | ]; 258 | 259 | await run(mockStdout, mockStdin, mockStderr, argv); 260 | 261 | var errors = JSON.parse(stdout)['errors']; 262 | assert.deepEqual( 263 | [ 264 | { 265 | message: 'The field `Query.a` is missing a description.', 266 | location: { column: 3, line: 2, file: fixturePath }, 267 | rule: 'fields-have-descriptions', 268 | }, 269 | ], 270 | errors 271 | ); 272 | }); 273 | 274 | it('validates a schema composed of multiple files (glob) and outputs in json', async () => { 275 | const argv = [ 276 | 'node', 277 | 'lib/cli.js', 278 | '--format', 279 | 'json', 280 | '--rules', 281 | 'fields-have-descriptions', 282 | '--ignore', 283 | '{"fields-have-descriptions": ["Obvious", "DontPanic.obvious"]}', 284 | '--rules-options', 285 | '{"enum-values-sorted-alphabetically":{"sortOrder":"lexicographical"}}', 286 | `${__dirname}/fixtures/schema/*.graphql`, 287 | ]; 288 | 289 | await run(mockStdout, mockStdin, mockStderr, argv); 290 | 291 | var errors = JSON.parse(stdout)['errors']; 292 | assert(errors); 293 | assert.equal(9, errors.length); 294 | }); 295 | 296 | it('validates a schema composed of multiple files (args) and outputs in json', async () => { 297 | const argv = [ 298 | 'node', 299 | 'lib/cli.js', 300 | '--format', 301 | 'json', 302 | '--rules', 303 | 'fields-have-descriptions', 304 | `${__dirname}/fixtures/schema/schema.graphql`, 305 | `${__dirname}/fixtures/schema/user.graphql`, 306 | ]; 307 | 308 | await run(mockStdout, mockStdin, mockStderr, argv); 309 | 310 | var errors = JSON.parse(stdout)['errors']; 311 | assert(errors); 312 | assert.equal(4, errors.length); 313 | }); 314 | 315 | it('preserves original line numbers when schema is composed of multiple files', async () => { 316 | const argv = [ 317 | 'node', 318 | 'lib/cli.js', 319 | '--format', 320 | 'json', 321 | '--rules', 322 | 'fields-have-descriptions', 323 | `${__dirname}/fixtures/schema/schema.graphql`, 324 | `${__dirname}/fixtures/schema/user.graphql`, 325 | `${__dirname}/fixtures/schema/comment.graphql`, 326 | ]; 327 | 328 | await run(mockStdout, mockStdin, mockStderr, argv); 329 | 330 | var errors = JSON.parse(stdout)['errors']; 331 | assert(errors); 332 | 333 | assert.equal(6, errors.length); 334 | 335 | assert.equal( 336 | 'The field `Query.something` is missing a description.', 337 | errors[0].message 338 | ); 339 | assert.equal(2, errors[0].location.line); 340 | assert.equal( 341 | `${__dirname}/fixtures/schema/schema.graphql`, 342 | errors[0].location.file 343 | ); 344 | assert.equal(errors[0].rule, 'fields-have-descriptions'); 345 | 346 | assert.equal( 347 | 'The field `User.username` is missing a description.', 348 | errors[1].message 349 | ); 350 | assert.equal(2, errors[1].location.line); 351 | assert.equal( 352 | `${__dirname}/fixtures/schema/user.graphql`, 353 | errors[1].location.file 354 | ); 355 | assert.equal(errors[1].rule, 'fields-have-descriptions'); 356 | 357 | assert.equal( 358 | 'The field `User.email` is missing a description.', 359 | errors[2].message 360 | ); 361 | assert.equal(3, errors[2].location.line); 362 | assert.equal( 363 | `${__dirname}/fixtures/schema/user.graphql`, 364 | errors[2].location.file 365 | ); 366 | assert.equal(errors[2].rule, 'fields-have-descriptions'); 367 | 368 | assert.equal( 369 | 'The field `Query.viewer` is missing a description.', 370 | errors[3].message 371 | ); 372 | assert.equal(7, errors[3].location.line); 373 | assert.equal( 374 | `${__dirname}/fixtures/schema/user.graphql`, 375 | errors[3].location.file 376 | ); 377 | assert.equal(errors[3].rule, 'fields-have-descriptions'); 378 | 379 | assert.equal( 380 | 'The field `Comment.body` is missing a description.', 381 | errors[4].message 382 | ); 383 | assert.equal(2, errors[4].location.line); 384 | assert.equal( 385 | `${__dirname}/fixtures/schema/comment.graphql`, 386 | errors[4].location.file 387 | ); 388 | assert.equal(errors[4].rule, 'fields-have-descriptions'); 389 | 390 | assert.equal( 391 | 'The field `Comment.author` is missing a description.', 392 | errors[5].message 393 | ); 394 | assert.equal(3, errors[5].location.line); 395 | assert.equal( 396 | `${__dirname}/fixtures/schema/comment.graphql`, 397 | errors[5].location.file 398 | ); 399 | assert.equal(errors[5].rule, 'fields-have-descriptions'); 400 | }); 401 | 402 | it('fails and exits if the output format is unknown', async () => { 403 | const argv = [ 404 | 'node', 405 | 'lib/cli.js', 406 | '--format', 407 | 'xml', 408 | '--rules', 409 | 'fields-have-descriptions', 410 | `${__dirname}/fixtures/valid.graphql`, 411 | ]; 412 | 413 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 414 | assert(stderr.indexOf("The output format 'xml' is invalid") >= 0); 415 | assert.equal(2, exitCode); 416 | }); 417 | 418 | it('warns but continues if a rule is unknown', async () => { 419 | const argv = [ 420 | 'node', 421 | 'lib/cli.js', 422 | '--rules', 423 | 'no-rule-of-mine,fields-have-descriptions', 424 | `${__dirname}/fixtures/valid.graphql`, 425 | ]; 426 | 427 | const exitCode = await run(mockStdout, mockStdin, mockStderr, argv); 428 | assert( 429 | stderr.indexOf('The following rule(s) are invalid: NoRuleOfMine') >= 0 430 | ); 431 | assert.equal(0, exitCode); 432 | }); 433 | }); 434 | }); 435 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-schema-linter [![Travis CI](https://travis-ci.org/cjoudrey/graphql-schema-linter.svg?branch=master)](https://travis-ci.org/cjoudrey/graphql-schema-linter) [![npm version](https://badge.fury.io/js/graphql-schema-linter.svg)](https://yarnpkg.com/en/package/graphql-schema-linter) 2 | 3 | This package provides a command line tool to validate GraphQL schema definitions against a set of rules. 4 | 5 | ![Screenshot](https://raw.githubusercontent.com/cjoudrey/graphql-schema-linter/master/screenshot-v0.0.24.png) 6 | 7 | If you're looking to lint your GraphQL queries, check out this ESLint plugin: [apollographql/eslint-plugin-graphql](https://github.com/apollographql/eslint-plugin-graphql). 8 | 9 | ## Install 10 | 11 | `graphql-schema-linter` depends on `graphql` as a peer dependency. 12 | 13 | In order to use `graphql-schema-linter`, you can either add it to an existing project that uses the `graphql` package: 14 | 15 | ``` 16 | # Using yarn 17 | yarn add graphql-schema-linter 18 | 19 | # Using npm 20 | npm install --save graphql-schema-linter 21 | ``` 22 | 23 | Or, you may install it globally along side `graphql`: 24 | 25 | ``` 26 | # Using yarn 27 | yarn global add graphql-schema-linter graphql 28 | 29 | # Using npm 30 | npm install -g graphql-schema-linter graphql 31 | ``` 32 | 33 | ## Usage 34 | 35 | ``` 36 | Usage: graphql-schema-linter [options] [schema.graphql ...] 37 | 38 | 39 | Options: 40 | 41 | -r, --rules 42 | 43 | only the rules specified will be used to validate the schema 44 | 45 | example: --rules fields-have-descriptions,types-have-descriptions 46 | 47 | -o, --rules-options 48 | 49 | configure the specified rules with the passed in configuration options 50 | 51 | example: --rules-options '{"enum-values-sorted-alphabetically":{"sortOrder":"lexicographical"}}' 52 | 53 | -i, --ignore 54 | 55 | ignore errors for specific schema members (see "Inline rule overrides" for an alternative way to do this) 56 | 57 | example: --ignore '{"fields-have-descriptions":["Obvious","Query.obvious","Query.something.obvious"]}' 58 | 59 | -f, --format 60 | 61 | choose the output format of the report 62 | 63 | possible values: compact, json, text 64 | 65 | -s, --stdin 66 | 67 | schema definition will be read from STDIN instead of specified file 68 | 69 | -c, --config-directory 70 | 71 | path to begin searching for config files 72 | 73 | -p, --custom-rule-paths 74 | 75 | path to additional custom rules to be loaded. Example: rules/*.js 76 | 77 | --comment-descriptions 78 | 79 | use old way of defining descriptions in GraphQL SDL 80 | 81 | --old-implements-syntax 82 | 83 | use old way of defining implemented interfaces in GraphQL SDL 84 | 85 | --version 86 | 87 | output the version number 88 | 89 | -h, --help 90 | 91 | output usage information 92 | ``` 93 | 94 | ### Usage with pre-commit Hooks 95 | 96 | Using [lint-staged](https://github.com/okonet/lint-staged) and [husky](https://github.com/typicode/husky), you can lint 97 | your staged GraphQL schema file before you commit. First, install these packages: 98 | 99 | ```bash 100 | yarn add --dev lint-staged husky 101 | ``` 102 | 103 | Then add a `precommit` script and a `lint-staged` key to your `package.json` like so: 104 | 105 | ```json 106 | { 107 | "scripts": { 108 | "precommit": "lint-staged" 109 | }, 110 | "lint-staged": { 111 | "*.graphql": ["graphql-schema-linter path/to/*.graphql"] 112 | } 113 | } 114 | ``` 115 | 116 | The above configuration assumes that you have either one `schema.graphql` file or multiple `.graphql` files that should 117 | be concatenated together and linted as a whole. 118 | 119 | If your project has `.graphql` query files and `.graphql` schema files, you'll likely need multiple entries in the 120 | `lint-staged` object - one for queries and one for schema. For example: 121 | 122 | ```json 123 | { 124 | "scripts": { 125 | "precommit": "lint-staged" 126 | }, 127 | "lint-staged": { 128 | "client/*.graphql": ["eslint . --ext .js --ext .gql --ext .graphql"], 129 | "server/*.graphql": ["graphql-schema-linter server/*.graphql"] 130 | } 131 | } 132 | ``` 133 | 134 | If you have multiple schemas in the same folder, your `lint-staged` configuration will need to be more specific, otherwise 135 | `graphql-schema-linter` will assume they are all parts of one schema. For example: 136 | 137 | **Correct:** 138 | 139 | ```json 140 | { 141 | "scripts": { 142 | "precommit": "lint-staged" 143 | }, 144 | "lint-staged": { 145 | "server/schema.public.graphql": ["graphql-schema-linter"], 146 | "server/schema.private.graphql": ["graphql-schema-linter"] 147 | } 148 | } 149 | ``` 150 | 151 | **Incorrect (if you have multiple schemas):** 152 | 153 | ```json 154 | { 155 | "scripts": { 156 | "precommit": "lint-staged" 157 | }, 158 | "lint-staged": { 159 | "server/*.graphql": ["graphql-schema-linter"] 160 | } 161 | } 162 | ``` 163 | 164 | ## Configuration file 165 | 166 | In addition to being able to configure `graphql-schema-linter` via command line options, it can also be configured via 167 | one of the following configuration files. 168 | 169 | For now, only `rules`, `schemaPaths`, `customRulePaths`, and `rulesOptions` can be configured in a configuration file, but more options may be added in the future. 170 | 171 | ### In `package.json` 172 | 173 | ```json 174 | { 175 | "graphql-schema-linter": { 176 | "rules": ["enum-values-sorted-alphabetically"], 177 | "schemaPaths": ["path/to/my/schema/files/**.graphql"], 178 | "customRulePaths": ["path/to/my/custom/rules/*.js"], 179 | "rulesOptions": { 180 | "enum-values-sorted-alphabetically": { "sortOrder": "lexicographical" } 181 | } 182 | } 183 | } 184 | ``` 185 | 186 | ### In `.graphql-schema-linterrc` 187 | 188 | ```json 189 | { 190 | "rules": ["enum-values-sorted-alphabetically"], 191 | "schemaPaths": ["path/to/my/schema/files/**.graphql"], 192 | "customRulePaths": ["path/to/my/custom/rules/*.js"], 193 | "rulesOptions": { 194 | "enum-values-sorted-alphabetically": { "sortOrder": "lexicographical" } 195 | } 196 | } 197 | ``` 198 | 199 | ### In `graphql-schema-linter.config.js` 200 | 201 | ```js 202 | module.exports = { 203 | rules: ['enum-values-sorted-alphabetically'], 204 | schemaPaths: ['path/to/my/schema/files/**.graphql'], 205 | customRulePaths: ['path/to/my/custom/rules/*.js'], 206 | rulesOptions: { 207 | 'enum-values-sorted-alphabetically': { sortOrder: 'lexicographical' } 208 | } 209 | }; 210 | ``` 211 | 212 | ## Inline rule overrides 213 | 214 | There could be cases where a linter rule is undesirable for a specific part of a GraphQL schema. 215 | 216 | Rather than disable the rule for the entire schema, it is possible to disable it for that specific part of the schema using an inline configuration. 217 | 218 | There are 4 different inline configurations: 219 | 220 | - `lint-disable rule1, rule2, ..., ruleN` will disable the specified rules, starting at the line it is defined, and until the end of the file or until the rule is re-enabled by an inline configuration. 221 | 222 | - `lint-enable rule1, rule2, ..., ruleN` will enable the specified rules, starting at the line it is defined, and until the end of the file or until the rule is disabled by an inline configuration. 223 | 224 | - `lint-disable-line rule1, rule2, ..., ruleN` will disable the specified rules for the given line. 225 | 226 | - `lint-enable-line rule1, rule2, ..., ruleN` will enable the specified rules for the given line. 227 | 228 | One can use these inline configurations by adding them directly to the GraphQL schema as comments. 229 | 230 | ```graphql 231 | # lint-disable types-have-descriptions, fields-have-descriptions 232 | type Query { 233 | field: String 234 | } 235 | # lint-enable types-have-descriptions, fields-have-descriptions 236 | 237 | """ 238 | Mutation root 239 | """ 240 | type Mutation { 241 | """ 242 | Field description 243 | """ 244 | field: String 245 | 246 | field2: String # lint-disable-line fields-have-descriptions 247 | } 248 | ``` 249 | 250 | **Note:** If you are authoring your GraphQL schema using a tool that prevents you from adding comments, you may use the `--ignore` to obtain the same functionality. 251 | 252 | ## Built-in rules 253 | 254 | ### `arguments-have-descriptions` 255 | 256 | This rule will validate that all field arguments have a description. 257 | 258 | ### `defined-types-are-used` 259 | 260 | This rule will validate that all defined types are used at least once in the schema. 261 | 262 | ### `deprecations-have-a-reason` 263 | 264 | This rule will validate that all deprecations have a reason. 265 | 266 | ### `descriptions-are-capitalized` 267 | 268 | This rule will validate that all descriptions, if present, start with a capital letter. 269 | 270 | ### `enum-values-all-caps` 271 | 272 | This rule will validate that all enum values are capitalized. 273 | 274 | ### `enum-values-have-descriptions` 275 | 276 | This rule will validate that all enum values have a description. 277 | 278 | ### `enum-values-sorted-alphabetically` 279 | 280 | This rule will validate that all enum values are sorted alphabetically. 281 | 282 | Accepts following rule options: 283 | 284 | - `sortOrder`: `` - either `alphabetical` or `lexicographical`, defaults: `alphabetical` 285 | 286 | ### `fields-are-camel-cased` 287 | 288 | This rule will validate that object type field and interface type field names are camel cased. 289 | 290 | ### `fields-have-descriptions` 291 | 292 | This rule will validate that object type fields and interface type fields have a description. 293 | 294 | ### `input-object-fields-sorted-alphabetically` 295 | 296 | This rule will validate that all input object fields are sorted alphabetically. 297 | 298 | Accepts following rule options: 299 | 300 | - `sortOrder`: `` - either `alphabetical` or `lexicographical`, defaults: `alphabetical` 301 | 302 | ### `input-object-values-are-camel-cased` 303 | 304 | This rule will validate that input object value names are camel cased. 305 | 306 | ### `input-object-values-have-descriptions` 307 | 308 | This rule will validate that input object values have a description. 309 | 310 | ### `interface-fields-sorted-alphabetically` 311 | 312 | This rule will validate that all interface object fields are sorted alphabetically. 313 | 314 | Accepts following rule options: 315 | 316 | - `sortOrder`: `` - either `alphabetical` or `lexicographical`, defaults: `alphabetical` 317 | 318 | ### `relay-connection-types-spec` 319 | 320 | This rule will validate the schema adheres to [section 2 (Connection Types)](https://facebook.github.io/relay/graphql/connections.htm#sec-Connection-Types) of the [Relay Cursor Connections Specification](https://facebook.github.io/relay/graphql/connections.htm). 321 | 322 | More specifically: 323 | 324 | - Only object type names may end in `Connection`. These object types are considered connection types. 325 | - Connection types must have a `edges` field that returns a list type. 326 | - Connection types must have a `pageInfo` field that returns a non-null `PageInfo` object. 327 | 328 | ### `relay-connection-arguments-spec` 329 | 330 | This rule will validate the schema adheres to [section 4 (Arguments)](https://facebook.github.io/relay/graphql/connections.htm#sec-Arguments) of the [Relay Cursor Connections Specification](https://facebook.github.io/relay/graphql/connections.htm). 331 | 332 | More specifically: 333 | 334 | - A field that returns a `Connection` must include forward pagination arguments, backward pagination arguments, or both. 335 | - To enable forward pagination, two arguments are required: `first: Int` and `after: *`. 336 | - To enable backward pagination, two arguments are required: `last: Int` and `before: *`. 337 | 338 | Note: If only forward pagination is enabled, the `first` argument can be specified as non-nullable (i.e., `Int!` instead of `Int`). Similarly, if only backward pagination is enabled, the `last` argument can be specified as non-nullable. 339 | 340 | This rule will validate the schema adheres to [section 5 (PageInfo)](https://facebook.github.io/relay/graphql/connections.htm#sec-undefined.PageInfo) of the [Relay Cursor Connections Specification](https://facebook.github.io/relay/graphql/connections.htm). 341 | 342 | More specifically: 343 | 344 | - A GraphQL schema must have a `PageInfo` object type. 345 | - `PageInfo` type must have a `hasNextPage: Boolean!` field. 346 | - `PageInfo` type must have a `hasPreviousPage: Boolean!` field. 347 | 348 | ### `type-fields-sorted-alphabetically` 349 | 350 | This rule will validate that all type object fields are sorted alphabetically. 351 | 352 | Accepts following rule options: 353 | 354 | - `sortOrder`: `` - either `alphabetical` or `lexicographical`, defaults: `alphabetical` 355 | 356 | ### `types-are-capitalized` 357 | 358 | This rule will validate that interface types and object types have capitalized names. 359 | 360 | ### `types-have-descriptions` 361 | 362 | This will will validate that interface types, object types, union types, scalar types, enum types and input types have descriptions. 363 | 364 | ## Output formatters 365 | 366 | The format of the output can be controlled via the `--format` option. 367 | 368 | The following formatters are currently available: `text`, `compact`, `json`. 369 | 370 | ### Text (default) 371 | 372 | Sample output: 373 | 374 | ``` 375 | app/schema.graphql 376 | 5:1 The object type `QueryRoot` is missing a description. types-have-descriptions 377 | 6:3 The field `QueryRoot.songs` is missing a description. fields-have-descriptions 378 | 379 | app/songs.graphql 380 | 1:1 The object type `Song` is missing a description. types-have-descriptions 381 | 382 | 3 errors detected 383 | ``` 384 | 385 | Each error is prefixed with the line number and column the error occurred on. 386 | 387 | ### Compact 388 | 389 | Sample output: 390 | 391 | ``` 392 | app/schema.graphql:5:1 The object type `QueryRoot` is missing a description. (types-have-descriptions) 393 | app/schema.graphql:6:3 The field `QueryRoot.a` is missing a description. (fields-have-descriptions) 394 | app/songs.graphql:1:1 The object type `Song` is missing a description. (types-have-descriptions) 395 | ``` 396 | 397 | Each error is prefixed with the path, the line number and column the error occurred on. 398 | 399 | ### JSON 400 | 401 | Sample output: 402 | 403 | ```json 404 | { 405 | "errors": [ 406 | { 407 | "message": "The object type `QueryRoot` is missing a description.", 408 | "location": { 409 | "line": 5, 410 | "column": 1, 411 | "file": "schema.graphql" 412 | }, 413 | "rule": "types-have-descriptions" 414 | }, 415 | { 416 | "message": "The field `QueryRoot.a` is missing a description.", 417 | "location": { 418 | "line": 6, 419 | "column": 3, 420 | "file": "schema.graphql" 421 | }, 422 | "rule": "fields-have-descriptions" 423 | } 424 | ] 425 | } 426 | ``` 427 | 428 | ## Exit codes 429 | 430 | Verifying the exit code of the `graphql-schema-lint` process is a good way of programmatically knowing the 431 | result of the validation. 432 | 433 | If the process exits with `0` it means all rules passed. 434 | 435 | If the process exits with `1` it means one or many rules failed. Information about these failures can be obtained by 436 | reading the `stdout` and using the appropriate output formatter. 437 | 438 | If the process exits with `2` it means an invalid configuration was provided. Information about this can be obtained by 439 | reading the `stderr`. 440 | 441 | If the process exits with `3` it means an uncaught error happened. This most likely means you found a bug. 442 | 443 | ## Customizing rules 444 | 445 | `graphql-schema-linter` comes with a set of rules, but it's possible that it doesn't exactly match your expectations. 446 | 447 | The `--rules ` allows you pick and choose what rules you want to use to validate your schema. 448 | 449 | In some cases, you may want to write your own rules. `graphql-schema-linter` leverages [GraphQL.js' visitor.js](https://github.com/graphql/graphql-js/blob/6f151233defaaed93fe8a9b38fa809f22e0f5928/src/language/visitor.js#L138) 450 | in order to validate a schema. 451 | 452 | You may define custom rules by following the usage of [visitor.js](https://github.com/graphql/graphql-js/blob/6f151233defaaed93fe8a9b38fa809f22e0f5928/src/language/visitor.js#L138) and saving your newly created rule as a `.js` file. 453 | 454 | You can then instruct `graphql-schema-linter` to include this rule using the `--custom-rule-paths ` option flag. 455 | 456 | For sample rules, see the [`src/rules`](https://github.com/cjoudrey/graphql-schema-linter/tree/master/src/rules) folder of this repository or 457 | GraphQL.js' [`src/validation/rules`](https://github.com/graphql/graphql-js/tree/6f151233defaaed93fe8a9b38fa809f22e0f5928/src/validation/rules) folder. 458 | --------------------------------------------------------------------------------