/** Script to check graphql schemas for breaking changes * Uses: https://www.npmjs.com/package/@graphql-inspector/core * The associated CLI tool (https://www.npmjs.com/package/@graphql-inspector/cli) * is simple to use but inflexible for our use case. */ /* eslint-disable no-console */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { CriticalityLevel, diff } from '@graphql-inspector/core'; import { green, red } from 'chalk'; import { execSync } from 'child_process'; import * as fs from 'fs'; import { GraphQLSchema, buildSchema } from 'graphql'; import * as path from 'path'; import { CommandModule } from 'yargs'; import { exitCode } from '../common'; interface GraphqlDiffOptions { files: (string | number)[]; branch: string; verbose: boolean; } export const graphqlDiff: CommandModule = { command: 'graphql-diff', describe: 'Compares graphql schema(s) with git for breaking changes.', builder: (yargs) => yargs .options('files', { alias: 'f', describe: 'The .graphql file(s) to check. Each will be checked against git.', type: 'array', demand: true, }) .option('branch', { alias: 'b', description: 'Git branch or commit identifier to compare to.', type: 'string', default: 'origin/master', }) .option('verbose', { alias: 'v', description: 'Verbose output including non-breaking changes', type: 'boolean', default: false, }), handler: async (argv) => { let totalBreaking = 0; try { for (const file of argv.files) { totalBreaking += await gqlDiff( file.toString(), argv.branch, argv.verbose, ); } } catch (e) { console.log(red(e)); process.exit(exitCode); } if (totalBreaking > 0) { process.exit(exitCode); } }, }; async function gqlDiff( file: string, branch: string, verbose: boolean, ): Promise { const root = path.dirname(__dirname); const relPath = path.relative(root, file); console.log(`Comparing schema '${relPath}' with branch '${branch}':`); let schema1: GraphQLSchema | undefined = undefined; let schema2: GraphQLSchema | undefined = undefined; // load schemas try { const buffer = await fs.promises.readFile(file); schema2 = buildSchema(buffer.toString()); } catch (e: any) { throw `Error reading ${file}: ${e.toString()}`; } try { const text = readFileFromGit(branch, path.resolve(file)); schema1 = buildSchema(text); } catch (e: any) { if (e.toString().includes(`exists on disk, but not in '${branch}'`)) { console.log(e.toString()); console.log( `WARNING: Check will be skipped on the assumption that this is a new schema.`, ); return 0; } throw `Error reading ${file} from branch ${branch}: ${e}`; } // generate diff const diffResult = await diff( schema1 as GraphQLSchema, schema2 as GraphQLSchema, ); const breaking = diffResult.filter( (r) => r.criticality.level === CriticalityLevel.Breaking, ); // log results if (breaking.length > 0 || verbose) { for (const item of diffResult) { if (item.criticality.level === CriticalityLevel.Breaking) { console.log(` ✖ ${item.message}`); } else { console.log(` ✔ ${item.message}`); } } } const color = breaking.length > 0 ? red : green; console.log( color( `${breaking.length > 0 ? '❌ ' : ''}${breaking.length} breaking change${ breaking.length === 1 ? '' : 's' }`, ), ); return breaking.length; } function readFileFromGit(branch: string, file: string): string { const root = execSync( `git -C ${path .dirname(file) .replace(/\\/g, '/')} rev-parse --show-toplevel`, ) .toString() .trim(); return execSync( `git -C ${root} show ${branch}:${path .relative(root, file) .replace(/\\/g, '/')}`, ).toString(); }