From 6ef1447ed31ebd859c28ceb95361c29f51449cd0 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 20 Jun 2024 18:44:20 +0100 Subject: [PATCH 01/14] Basic middleware system --- src/index.ts | 28 ++++++++++++++++++++++++++++ src/interfaces.ts | 4 ++++ src/worker.ts | 31 +++++++++++++++++++++---------- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/src/index.ts b/src/index.ts index 4e2983d..bf67ac2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,7 @@ +import "graphile-config"; +import { CallbackOrDescriptor, MiddlewareNext } from "graphile-config"; +import { CheckDocumentEvent, CheckDocumentOutput } from "./interfaces.js"; + export { filterBaseline, generateBaseline } from "./baseline.js"; export { Baseline, @@ -21,6 +25,7 @@ declare global { config?: GraphileConfig.GraphQLCheckConfig; }; } + interface GraphQLCheckConfig { maxDepth?: number; maxListDepth?: number; @@ -32,5 +37,28 @@ declare global { [fieldCoordinate: string]: number; }; } + + interface Plugin { + gqlcheck?: { + middleware?: { + [key in keyof GqlcheckMiddleware]?: CallbackOrDescriptor< + GqlcheckMiddleware[key] extends ( + ...args: infer UArgs + ) => infer UResult + ? (next: MiddlewareNext, ...args: UArgs) => UResult + : never + >; + }; + }; + } + + interface GqlcheckMiddleware { + checkDocument( + event: CheckDocumentEvent, + ): PromiseLike; + } } } + +export type PromiseOrDirect = PromiseLike | T; +export type TruePromiseOrDirect = Promise | T; diff --git a/src/interfaces.ts b/src/interfaces.ts index bb9dc8c..5728694 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -59,3 +59,7 @@ export interface Baseline { | undefined; }; } + +export interface CheckDocumentEvent { + req: CheckDocumentRequest; +} diff --git a/src/worker.ts b/src/worker.ts index 11bad8b..dd620df 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { isMainThread, parentPort, workerData } from "node:worker_threads"; -import { resolvePresets } from "graphile-config"; +import { Middleware, orderedApply, resolvePresets } from "graphile-config"; import { loadConfig } from "graphile-config/load"; import type { GraphQLError, GraphQLFormattedError } from "graphql"; import { @@ -53,6 +53,15 @@ async function main() { gqlcheck: { schemaSdlPath = `${process.cwd()}/schema.graphql` } = {}, } = config; + const middleware = new Middleware(); + orderedApply( + config.plugins, + (p) => p.gqlcheck?.middleware, + (name, fn, _plugin) => { + middleware.register(name, fn as any); + }, + ); + const schemaString = readFileSync(schemaSdlPath, "utf8"); const schema = buildASTSchema(parse(schemaString)); { @@ -128,15 +137,17 @@ async function main() { if (req === "STOP") { process.exit(0); } - checkDocument(req).then( - (result) => { - definitelyParentPort.postMessage(result); - }, - (e) => { - console.dir(e); - process.exit(1); - }, - ); + middleware + .run("checkDocument", { req }, ({ req }) => checkDocument(req)) + .then( + (result) => { + definitelyParentPort.postMessage(result); + }, + (e) => { + console.dir(e); + process.exit(1); + }, + ); }); definitelyParentPort.postMessage("READY"); From 0d55b0d6bdbd5ca7670156d29ca3ecc5bf0f2a01 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Thu, 20 Jun 2024 18:51:51 +0100 Subject: [PATCH 02/14] More middlewares --- src/index.ts | 10 +++++++++- src/interfaces.ts | 11 ++++++++++- src/worker.ts | 14 ++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index bf67ac2..91ab23f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,12 @@ import "graphile-config"; import { CallbackOrDescriptor, MiddlewareNext } from "graphile-config"; -import { CheckDocumentEvent, CheckDocumentOutput } from "./interfaces.js"; +import { + CheckDocumentEvent, + CheckDocumentOutput, + CreateVisitorEvent, + VisitorsEvent, +} from "./interfaces.js"; +import { ASTVisitor } from "graphql"; export { filterBaseline, generateBaseline } from "./baseline.js"; export { @@ -56,6 +62,8 @@ declare global { checkDocument( event: CheckDocumentEvent, ): PromiseLike; + visitors(event: VisitorsEvent): PromiseOrDirect; + createVisitor(event: CreateVisitorEvent): PromiseOrDirect; } } } diff --git a/src/interfaces.ts b/src/interfaces.ts index 5728694..cbf6454 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,5 @@ -import type { GraphQLFormattedError } from "graphql"; +import type { ASTVisitor, GraphQLFormattedError } from "graphql"; +import { TypeAndOperationPathInfo } from "./operationPaths"; export interface WorkerData { configPath: string | null | undefined; @@ -63,3 +64,11 @@ export interface Baseline { export interface CheckDocumentEvent { req: CheckDocumentRequest; } +export interface VisitorsEvent { + typeInfo: TypeAndOperationPathInfo; + visitors: ASTVisitor[]; +} +export interface CreateVisitorEvent { + typeInfo: TypeAndOperationPathInfo; + visitors: ASTVisitor[]; +} diff --git a/src/worker.ts b/src/worker.ts index dd620df..3d05006 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -111,8 +111,18 @@ async function main() { config, onError, ); - const visitor = visitInParallel([DepthVisitor(rulesContext)]); - visit(document, visitWithTypeInfo(typeInfo, visitor)); + const visitors = await middleware.run( + "visitors", + { typeInfo, visitors: [DepthVisitor(rulesContext)] }, + ({ visitors }) => visitors, + ); + const visitor = await middleware.run( + "createVisitor", + { typeInfo, visitors }, + ({ typeInfo, visitors }) => + visitWithTypeInfo(typeInfo, visitInParallel(visitors)), + ); + visit(document, visitor); const operations = operationDefinitions.map( (operationDefinition): CheckDocumentOperationResult => { From b842b781563fd4735680149cfe52414293dfdf94 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 12:48:12 +0100 Subject: [PATCH 03/14] Replace how operation names and paths are determined to minimise work required of future rules --- src/DepthVisitor.ts | 46 ++++++------ src/OperationPathsVisitor.ts | 14 ++++ src/baseline.ts | 49 ++++++------- src/index.ts | 10 +-- src/interfaces.ts | 10 +-- src/operationPaths.ts | 32 ++++++++- src/ruleError.ts | 8 +-- src/rulesContext.ts | 134 ++++++++++++++++++++++++++++++++++- src/worker.ts | 55 +++++++++----- 9 files changed, 274 insertions(+), 84 deletions(-) create mode 100644 src/OperationPathsVisitor.ts diff --git a/src/DepthVisitor.ts b/src/DepthVisitor.ts index df49a3c..8f6a9a3 100644 --- a/src/DepthVisitor.ts +++ b/src/DepthVisitor.ts @@ -1,6 +1,7 @@ import * as assert from "node:assert"; import type { + ASTNode, ASTVisitor, FragmentDefinitionNode, GraphQLOutputType, @@ -20,14 +21,14 @@ import type { RulesContext } from "./rulesContext.js"; interface DepthInfo { current: number; max: number; - coordsByDepth: Map; + nodesByDepth: Map; } function newDepthInfo(): DepthInfo { return { current: 0, max: 0, - coordsByDepth: new Map(), + nodesByDepth: new Map(), }; } @@ -126,7 +127,7 @@ function resolveFragment( try { // Step 1: add all the fragments own depths for (const key of Object.keys(fragmentRoot.depths) as (keyof Depths)[]) { - const { max: fragMax, coordsByDepth: fragCoordsByDepth } = + const { max: fragMax, nodesByDepth: fragNodesByDepth } = fragmentRoot.depths[key]!; if (!depths[key]) { depths[key] = newDepthInfo(); @@ -135,15 +136,15 @@ function resolveFragment( if (adjustedMax > depths[key].max) { depths[key].max = adjustedMax; } - for (const [fragDepth, fragCoords] of fragCoordsByDepth) { - const transformedCoords = fragCoords.map((c) => `${operationPath}${c}`); + for (const [fragDepth, fragNodes] of fragNodesByDepth) { + //const transformedCoords = fragNodes.map((c) => `${operationPath}${c}`); const depth = depths[key].current + fragDepth; - const list = depths[key].coordsByDepth.get(depth); + const list = depths[key].nodesByDepth.get(depth); if (list) { // More performant than list.push(...transformedCoords) - transformedCoords.forEach((c) => list.push(c)); + fragNodes.forEach((c) => list.push(c)); } else { - depths[key].coordsByDepth.set(depth, transformedCoords); + depths[key].nodesByDepth.set(depth, [...fragNodes]); } } } @@ -211,9 +212,7 @@ function resolveOperationRoot( depths[key] = newDepthInfo(); } depths[key].max = operationRoot.depths[key]!.max; - depths[key].coordsByDepth = new Map( - operationRoot.depths[key]!.coordsByDepth, - ); + depths[key].nodesByDepth = new Map(operationRoot.depths[key]!.nodesByDepth); } traverseFragmentReferences( fragmentRootByName, @@ -251,7 +250,7 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { let state: State = newState(); state.complete = true; let currentRoot: Root | null = null; - function incDepth(key: TKey) { + function incDepth(key: TKey, node: ASTNode) { if (!currentRoot) { throw new Error( `DepthVisitor attempted to increment depth, but there's no currentRoot!`, @@ -265,13 +264,9 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { const { current, max } = currentRoot.depths[key]; if (current > max) { currentRoot.depths[key].max = current; - currentRoot.depths[key].coordsByDepth.set(current, [ - context.getOperationPath(), - ]); + currentRoot.depths[key].nodesByDepth.set(current, [node]); } else if (current === max) { - currentRoot.depths[key].coordsByDepth - .get(current)! - .push(context.getOperationPath()); + currentRoot.depths[key].nodesByDepth.get(current)!.push(node); } } @@ -346,7 +341,7 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { // Now see if we've exceeded the limits for (const key of Object.keys(resolvedOperation.depths)) { - const { max, coordsByDepth } = resolvedOperation.depths[key]!; + const { max, nodesByDepth } = resolvedOperation.depths[key]!; const selfReferential = key.includes("."); const [limit, override, infraction, label] = ((): [ limit: number, @@ -406,18 +401,15 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { } })(); if (max > limit) { - const operationCoordinates: string[] = []; + const nodes: ASTNode[] = []; for (let i = limit + 1; i <= max; i++) { - coordsByDepth - .get(i) - ?.forEach((c) => operationCoordinates.push(c)); + nodesByDepth.get(i)?.forEach((c) => nodes.push(c)); } const error = new RuleError( `${label} exceeded: ${max} > ${limit}`, { infraction, - operationName, - operationCoordinates, + nodes, override, }, ); @@ -506,13 +498,15 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { if (!parentType) return; const { namedType, listDepth } = processType(returnType); if (isCompositeType(namedType)) { - incDepth(`${parentType.name}.${node.name.value}`); + incDepth(`${parentType.name}.${node.name.value}`, node); incDepth( context.isIntrospection() ? "introspectionFields" : "fields", + node, ); for (let i = 0; i < listDepth; i++) { incDepth( context.isIntrospection() ? "introspectionLists" : "lists", + node, ); } } diff --git a/src/OperationPathsVisitor.ts b/src/OperationPathsVisitor.ts new file mode 100644 index 0000000..22dffeb --- /dev/null +++ b/src/OperationPathsVisitor.ts @@ -0,0 +1,14 @@ +import { type ASTVisitor, Kind } from "graphql"; + +import type { RulesContext } from "./rulesContext"; + +export function OperationPathsVisitor(context: RulesContext): ASTVisitor { + return { + enter(node) { + context.initEnterNode(node); + }, + leave(node) { + context.initLeaveNode(node); + }, + }; +} diff --git a/src/baseline.ts b/src/baseline.ts index 1d1c127..ecf8a1a 100644 --- a/src/baseline.ts +++ b/src/baseline.ts @@ -16,20 +16,23 @@ export function generateBaseline( for (const error of errors) { if ("infraction" in error) { // Rule error - const { operationName, infraction, operationCoordinates } = error; - if (!operationName) continue; - if (!baseline.operations[operationName]) { - baseline.operations[operationName] = { - ignoreCoordinatesByRule: Object.create(null), - }; - } - const op = baseline.operations[operationName]; - if (!op.ignoreCoordinatesByRule[infraction]) { - op.ignoreCoordinatesByRule[infraction] = []; - } - const ignores = op.ignoreCoordinatesByRule[infraction]; - for (const coord of operationCoordinates) { - ignores.push(coord); + const { operationNames, infraction, operationCoordinates } = error; + if (!operationNames) continue; + for (const operationName of operationNames) { + if (!operationName) continue; + if (!baseline.operations[operationName]) { + baseline.operations[operationName] = { + ignoreCoordinatesByRule: Object.create(null), + }; + } + const op = baseline.operations[operationName]; + if (!op.ignoreCoordinatesByRule[infraction]) { + op.ignoreCoordinatesByRule[infraction] = []; + } + const ignores = op.ignoreCoordinatesByRule[infraction]; + for (const coord of operationCoordinates) { + ignores.push(coord); + } } } } @@ -49,20 +52,18 @@ function filterOutput( if ("infraction" in e) { const { infraction, - operationName, + operationNames, operationCoordinates: rawCoords, } = e; - if (!operationName) { + if (!operationNames) { return e; } - if (!baseline.operations[operationName]) { - return e; - } - const ignores = - baseline.operations[operationName].ignoreCoordinatesByRule[ - infraction - ]; - if (!ignores) { + const ignores = operationNames.flatMap((n) => + n + ? baseline.operations[n]?.ignoreCoordinatesByRule[infraction] ?? [] + : [], + ); + if (ignores.length === 0) { return e; } const operationCoordinates = rawCoords.filter( diff --git a/src/index.ts b/src/index.ts index 91ab23f..adc8e66 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ -import "graphile-config"; -import { CallbackOrDescriptor, MiddlewareNext } from "graphile-config"; -import { +import type { CallbackOrDescriptor, MiddlewareNext } from "graphile-config"; +import type { ASTVisitor } from "graphql"; + +import type { CheckDocumentEvent, CheckDocumentOutput, CreateVisitorEvent, VisitorsEvent, } from "./interfaces.js"; -import { ASTVisitor } from "graphql"; export { filterBaseline, generateBaseline } from "./baseline.js"; export { @@ -17,6 +17,8 @@ export { } from "./interfaces.js"; export { checkOperations } from "./main.js"; export { printResults } from "./print.js"; +export { RuleError } from "./ruleError.js"; +export { RulesContext } from "./rulesContext.js"; declare global { namespace GraphileConfig { diff --git a/src/interfaces.ts b/src/interfaces.ts index cbf6454..1f975af 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,5 +1,7 @@ import type { ASTVisitor, GraphQLFormattedError } from "graphql"; + import { TypeAndOperationPathInfo } from "./operationPaths"; +import type { RulesContext } from "./rulesContext"; export interface WorkerData { configPath: string | null | undefined; @@ -36,8 +38,8 @@ export interface SourceResultsBySourceName { export interface RuleFormattedError extends GraphQLFormattedError { infraction: string; - operationName: string | undefined; - operationCoordinates: string[]; + operationNames: ReadonlyArray; + operationCoordinates: ReadonlyArray; override: GraphileConfig.GraphQLCheckConfig; } @@ -65,10 +67,10 @@ export interface CheckDocumentEvent { req: CheckDocumentRequest; } export interface VisitorsEvent { - typeInfo: TypeAndOperationPathInfo; + rulesContext: RulesContext; visitors: ASTVisitor[]; } export interface CreateVisitorEvent { - typeInfo: TypeAndOperationPathInfo; + rulesContext: RulesContext; visitors: ASTVisitor[]; } diff --git a/src/operationPaths.ts b/src/operationPaths.ts index 6abbe7e..c7fc6a5 100644 --- a/src/operationPaths.ts +++ b/src/operationPaths.ts @@ -1,13 +1,27 @@ import * as assert from "node:assert"; -import type { ASTNode } from "graphql"; +import type { + ASTNode, + FragmentDefinitionNode, + OperationDefinitionNode, +} from "graphql"; import { Kind, TypeInfo } from "graphql"; export class TypeAndOperationPathInfo extends TypeInfo { operationPathParts: string[] = []; _introspectionDepth = 0; + currentRoot: FragmentDefinitionNode | OperationDefinitionNode | null = null; enter(node: ASTNode) { + if ( + node.kind === Kind.FRAGMENT_DEFINITION || + node.kind === Kind.OPERATION_DEFINITION + ) { + if (this.currentRoot) { + throw new Error("There should be no root at this time"); + } + this.currentRoot = node; + } this.enterOperationPath(node); if ( node.kind === Kind.FRAGMENT_DEFINITION && @@ -32,9 +46,24 @@ export class TypeAndOperationPathInfo extends TypeInfo { this._introspectionDepth--; } this.leaveOperationPath(node); + if ( + node.kind === Kind.FRAGMENT_DEFINITION || + node.kind === Kind.OPERATION_DEFINITION + ) { + if (!this.currentRoot) { + throw new Error( + "There should have been a root when leaving a fragment/operation", + ); + } + this.currentRoot = null; + } return result; } + getCurrentRoot() { + return this.currentRoot; + } + enterOperationPath(node: ASTNode) { switch (node.kind) { case Kind.SELECTION_SET: { @@ -159,7 +188,6 @@ export class TypeAndOperationPathInfo extends TypeInfo { getOperationPath() { return this.operationPathParts.join(""); } - isIntrospection() { return this._introspectionDepth > 0; } diff --git a/src/ruleError.ts b/src/ruleError.ts index 1e79d7a..a0752e2 100644 --- a/src/ruleError.ts +++ b/src/ruleError.ts @@ -2,13 +2,12 @@ import type { GraphQLErrorOptions } from "graphql"; import { formatError, GraphQLError, version as GraphQLVersion } from "graphql"; import type { RuleFormattedError } from "./interfaces"; +import type { RulesContext } from "./rulesContext"; const graphqlMajor = parseInt(GraphQLVersion.split(".")[0], 10); export interface RuleErrorOptions extends GraphQLErrorOptions { infraction: string; - operationName: string | undefined; - operationCoordinates: string[]; /** What needs to be added to the overrides for this coordinate for this error to be ignored? */ override: GraphileConfig.GraphQLCheckConfig; } @@ -31,12 +30,11 @@ export class RuleError extends GraphQLError { } Object.defineProperty(this, "options", { value: options }); } - toJSON(): RuleFormattedError { + toJSONEnhanced(context: RulesContext): RuleFormattedError { return { ...(super.toJSON?.() ?? formatError(this)), infraction: this.options.infraction, - operationName: this.options.operationName, - operationCoordinates: this.options.operationCoordinates, + ...context.getOperationNamesAndCoordinatesForNodes(this.nodes), override: this.options.override, }; } diff --git a/src/rulesContext.ts b/src/rulesContext.ts index 4d95482..e94fe43 100644 --- a/src/rulesContext.ts +++ b/src/rulesContext.ts @@ -1,5 +1,13 @@ -import type { DocumentNode, GraphQLError, GraphQLSchema } from "graphql"; -import { ValidationContext } from "graphql"; +import type { + ASTNode, + DocumentNode, + FragmentDefinitionNode, + FragmentSpreadNode, + GraphQLError, + GraphQLSchema, + OperationDefinitionNode, +} from "graphql"; +import { Kind, ValidationContext } from "graphql"; import type { TypeAndOperationPathInfo } from "./operationPaths"; import type { RuleError } from "./ruleError"; @@ -14,6 +22,9 @@ export class RulesContext extends ValidationContext { ) { super(schema, ast, typeInfo, (error) => onError(error)); } + getTypeInfo() { + return this.typeInfo; + } getOperationPath() { return this.typeInfo.getOperationPath(); } @@ -23,4 +34,123 @@ export class RulesContext extends ValidationContext { isIntrospection() { return this.typeInfo.isIntrospection(); } + operationPathByNode = new Map(); + operationNamesByNode = new Map>(); + + _fragmentsByRoot = new Map< + FragmentDefinitionNode | OperationDefinitionNode, + FragmentSpreadNode[] + >(); + _nodesByRoot = new Map< + FragmentDefinitionNode | OperationDefinitionNode, + Array + >(); + _operationDefinitions: OperationDefinitionNode[] = []; + _fragmentDefinitions: FragmentDefinitionNode[] = []; + initEnterNode(node: ASTNode) { + const root = this.typeInfo.getCurrentRoot(); + if (root != null) { + let list = this._nodesByRoot.get(root); + if (!list) { + list = []; + this._nodesByRoot.set(root, list); + } + list.push(node); + } + if (node.kind === Kind.OPERATION_DEFINITION) { + this._operationDefinitions.push(node); + } + if (node.kind === Kind.FRAGMENT_DEFINITION) { + this._fragmentDefinitions.push(node); + } + if (node.kind === Kind.FRAGMENT_SPREAD) { + if (!this.typeInfo.currentRoot) { + throw new Error( + "Cannot have a fragment spread without being inside a fragment definition/operation", + ); + } + let list = this._fragmentsByRoot.get(this.typeInfo.currentRoot); + if (!list) { + list = []; + this._fragmentsByRoot.set(this.typeInfo.currentRoot, list); + } + list.push(node); + } + this.operationPathByNode.set(node, this.typeInfo.getOperationPath()); + } + initLeaveNode(node: ASTNode) { + if (node.kind === Kind.DOCUMENT) { + // Finalize + for (const operationDefinition of this._operationDefinitions) { + const operationName = operationDefinition.name?.value; + const walk = ( + root: OperationDefinitionNode | FragmentDefinitionNode, + visited: Set, + ) => { + // This runs before we've ensured there's no cycles, so we must protect ourself + if (visited.has(root)) { + return; + } + visited.add(root); + // Every node in this root is within operationName + const nodes = this._nodesByRoot.get(root); + if (nodes) { + for (const node of nodes) { + let list = this.operationNamesByNode.get(node); + if (!list) { + list = []; + this.operationNamesByNode.set(node, list); + } + list.push(operationName); + } + } + const fragSpreads = this._fragmentsByRoot.get(root); + if (fragSpreads) { + for (const fragSpread of fragSpreads) { + const frag = this._fragmentDefinitions.find( + (d) => d.name.value === fragSpread.name.value, + ); + if (frag) { + walk(frag, visited); + } + } + } + visited.delete(root); + }; + walk(operationDefinition, new Set()); + } + } + } + getOperationNamesAndCoordinatesForNodes( + nodes: readonly ASTNode[] | undefined, + ): { + operationNames: readonly (string | undefined)[]; + operationCoordinates: string[]; + } { + if (nodes == null) { + console.log(`No nodes!`); + return { + operationNames: [], + operationCoordinates: [], + }; + } + const operationNames = new Set(); + const operationCoordinates = new Set(); + for (const node of nodes) { + const nodeOperationNames = this.operationNamesByNode.get(node); + if (nodeOperationNames) { + for (const operationName of nodeOperationNames) { + operationNames.add(operationName); + } + } + const nodeOperationCoordinate = this.operationPathByNode.get(node); + if (nodeOperationCoordinate) { + operationCoordinates.add(nodeOperationCoordinate); + } + } + return { + operationNames: [...operationNames], + operationCoordinates: [...operationCoordinates], + }; + } } diff --git a/src/worker.ts b/src/worker.ts index 3d05006..172c347 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -10,6 +10,7 @@ import { Kind, parse, Source, + specifiedRules, validate, validateSchema, visit, @@ -26,6 +27,7 @@ import type { WorkerData, } from "./interfaces.js"; import { TypeAndOperationPathInfo } from "./operationPaths.js"; +import { OperationPathsVisitor } from "./OperationPathsVisitor.js"; import type { RuleError } from "./ruleError.js"; import { RulesContext } from "./rulesContext.js"; @@ -89,20 +91,16 @@ async function main() { }; } - // TODO: regular validation - const validationErrors = validate(schema, document); - if (validationErrors.length > 0) { - return { - sourceName, - operations: [], - errors: validationErrors.map((e) => e.toJSON?.() ?? formatError(e)), - }; - } - const typeInfo = new TypeAndOperationPathInfo(schema); const errors: (RuleFormattedError | GraphQLFormattedError)[] = []; - function onError(error: RuleError | GraphQLError) { - errors.push(error.toJSON?.() ?? formatError(error)); + function onError( + error: RuleError | (GraphQLError & { toJSONEnhanced?: undefined }), + ) { + errors.push( + error.toJSONEnhanced?.(rulesContext) ?? + error.toJSON?.() ?? + formatError(error), + ); } const rulesContext = new RulesContext( schema, @@ -111,16 +109,40 @@ async function main() { config, onError, ); + + const baseValidationRules = [ + ...specifiedRules, + // We need to run this so we know what the operation path/operation names are for rule errors. + () => OperationPathsVisitor(rulesContext), + ]; + const validationErrors = validate( + schema, + document, + baseValidationRules, + {}, + typeInfo, + ); + if (validationErrors.length > 0) { + return { + sourceName, + operations: [], + errors: validationErrors.map((e) => e.toJSON?.() ?? formatError(e)), + }; + } + const visitors = await middleware.run( "visitors", - { typeInfo, visitors: [DepthVisitor(rulesContext)] }, + { rulesContext, visitors: [DepthVisitor(rulesContext)] }, ({ visitors }) => visitors, ); const visitor = await middleware.run( "createVisitor", - { typeInfo, visitors }, - ({ typeInfo, visitors }) => - visitWithTypeInfo(typeInfo, visitInParallel(visitors)), + { rulesContext, visitors }, + ({ rulesContext, visitors }) => + visitWithTypeInfo( + rulesContext.getTypeInfo(), + visitInParallel(visitors), + ), ); visit(document, visitor); @@ -159,7 +181,6 @@ async function main() { }, ); }); - definitelyParentPort.postMessage("READY"); } From 587a1bb9c3890bc6bbab3d8292b0486bb29702d3 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 13:33:45 +0100 Subject: [PATCH 04/14] Refactor again --- src/baseline.ts | 59 ++++++++++++++++-------------- src/interfaces.ts | 10 ++++-- src/print.ts | 3 +- src/ruleError.ts | 7 ++-- src/rulesContext.ts | 88 ++++++++++++++++++++++++++++++++------------- src/worker.ts | 8 ++++- 6 files changed, 118 insertions(+), 57 deletions(-) diff --git a/src/baseline.ts b/src/baseline.ts index ecf8a1a..916135a 100644 --- a/src/baseline.ts +++ b/src/baseline.ts @@ -16,9 +16,9 @@ export function generateBaseline( for (const error of errors) { if ("infraction" in error) { // Rule error - const { operationNames, infraction, operationCoordinates } = error; - if (!operationNames) continue; - for (const operationName of operationNames) { + const { operations, infraction } = error; + if (!operations) continue; + for (const { operationName, operationCoordinates } of operations) { if (!operationName) continue; if (!baseline.operations[operationName]) { baseline.operations[operationName] = { @@ -50,33 +50,40 @@ function filterOutput( const errors = rawErrors .map((e) => { if ("infraction" in e) { - const { - infraction, - operationNames, - operationCoordinates: rawCoords, - } = e; - if (!operationNames) { + const { infraction, operations: rawOperations } = e; + if (!rawOperations) { return e; } - const ignores = operationNames.flatMap((n) => - n - ? baseline.operations[n]?.ignoreCoordinatesByRule[infraction] ?? [] - : [], - ); - if (ignores.length === 0) { - return e; - } - const operationCoordinates = rawCoords.filter( - (c) => !ignores.includes(c), - ); - if (operationCoordinates.length === 0) { - // Fully ignored + const operations = rawOperations + .map((op) => { + const { operationName, operationCoordinates: rawCoords } = op; + if (operationName == null) { + return op; + } + const ignores = + baseline.operations[operationName]?.ignoreCoordinatesByRule[ + infraction + ] ?? []; + if (ignores.length === 0) { + return op; + } + const operationCoordinates = rawCoords.filter( + (c) => !ignores.includes(c), + ); + if (operationCoordinates.length === 0) { + // Fully ignored + return null; + } + op.operationCoordinates = operationCoordinates; + return op; + }) + .filter((o) => o != null); + if (operations.length === 0) { return null; + } else { + e.operations = operations; + return e; } - return { - ...e, - operationCoordinates, - }; } else { return e; } diff --git a/src/interfaces.ts b/src/interfaces.ts index 1f975af..9aec587 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -38,8 +38,10 @@ export interface SourceResultsBySourceName { export interface RuleFormattedError extends GraphQLFormattedError { infraction: string; - operationNames: ReadonlyArray; - operationCoordinates: ReadonlyArray; + operations: ReadonlyArray<{ + operationName: string | undefined; + operationCoordinates: ReadonlyArray; + }>; override: GraphileConfig.GraphQLCheckConfig; } @@ -74,3 +76,7 @@ export interface CreateVisitorEvent { rulesContext: RulesContext; visitors: ASTVisitor[]; } +export interface ErrorOperationLocation { + operationName: string | undefined; + operationCoordinates: string[]; +} diff --git a/src/print.ts b/src/print.ts index 34f9e96..688700a 100644 --- a/src/print.ts +++ b/src/print.ts @@ -20,7 +20,8 @@ function printGraphQLFormattedError(error: GraphQLFormattedError) { return `${printGraphQLFormattedErrorLocations(error)}${error.message}`; } function printRuleFormattedError(error: RuleFormattedError) { - return `${printGraphQLFormattedErrorLocations(error)}${error.message}\nProblematic paths:\n- ${error.operationCoordinates.slice(0, 10).join("\n- ")}${error.operationCoordinates.length > 10 ? "\n- ..." : ""}`; + const opCoords = error.operations.flatMap((o) => o.operationCoordinates); + return `${printGraphQLFormattedErrorLocations(error)}${error.message}\nProblematic paths:\n- ${opCoords.slice(0, 10).join("\n- ")}${opCoords.length > 10 ? "\n- ..." : ""}`; } export function printResults(result: CheckOperationsResult, _detailed = false) { diff --git a/src/ruleError.ts b/src/ruleError.ts index a0752e2..20e57c9 100644 --- a/src/ruleError.ts +++ b/src/ruleError.ts @@ -1,7 +1,7 @@ import type { GraphQLErrorOptions } from "graphql"; import { formatError, GraphQLError, version as GraphQLVersion } from "graphql"; -import type { RuleFormattedError } from "./interfaces"; +import type { ErrorOperationLocation, RuleFormattedError } from "./interfaces"; import type { RulesContext } from "./rulesContext"; const graphqlMajor = parseInt(GraphQLVersion.split(".")[0], 10); @@ -10,6 +10,7 @@ export interface RuleErrorOptions extends GraphQLErrorOptions { infraction: string; /** What needs to be added to the overrides for this coordinate for this error to be ignored? */ override: GraphileConfig.GraphQLCheckConfig; + errorOperationLocations?: readonly ErrorOperationLocation[]; } export class RuleError extends GraphQLError { @@ -34,7 +35,9 @@ export class RuleError extends GraphQLError { return { ...(super.toJSON?.() ?? formatError(this)), infraction: this.options.infraction, - ...context.getOperationNamesAndCoordinatesForNodes(this.nodes), + operations: + this.options.errorOperationLocations ?? + context.getErrorOperationLocationsForNodes(this.nodes), override: this.options.override, }; } diff --git a/src/rulesContext.ts b/src/rulesContext.ts index e94fe43..6bd8668 100644 --- a/src/rulesContext.ts +++ b/src/rulesContext.ts @@ -9,6 +9,7 @@ import type { } from "graphql"; import { Kind, ValidationContext } from "graphql"; +import type { ErrorOperationLocation } from "./interfaces"; import type { TypeAndOperationPathInfo } from "./operationPaths"; import type { RuleError } from "./ruleError"; @@ -34,7 +35,12 @@ export class RulesContext extends ValidationContext { isIntrospection() { return this.typeInfo.isIntrospection(); } - operationPathByNode = new Map(); + // Operation path but only relative to the root (which could be a fragment) + subPathByNode = new Map(); + operationPathsByNodeByOperation = new Map< + OperationDefinitionNode, + Map + >(); operationNamesByNode = new Map>(); _fragmentsByRoot = new Map< @@ -76,15 +82,21 @@ export class RulesContext extends ValidationContext { } list.push(node); } - this.operationPathByNode.set(node, this.typeInfo.getOperationPath()); + this.subPathByNode.set(node, this.typeInfo.getOperationPath()); } initLeaveNode(node: ASTNode) { if (node.kind === Kind.DOCUMENT) { // Finalize for (const operationDefinition of this._operationDefinitions) { const operationName = operationDefinition.name?.value; + const operationPathsByNode: Map = new Map(); + this.operationPathsByNodeByOperation.set( + operationDefinition, + operationPathsByNode, + ); const walk = ( root: OperationDefinitionNode | FragmentDefinitionNode, + path: string, visited: Set, ) => { // This runs before we've ensured there's no cycles, so we must protect ourself @@ -102,6 +114,16 @@ export class RulesContext extends ValidationContext { this.operationNamesByNode.set(node, list); } list.push(operationName); + const subpath = this.subPathByNode.get(node); + if (subpath != null) { + const fullPath = path + subpath; + let list = operationPathsByNode.get(node); + if (!list) { + list = []; + operationPathsByNode.set(node, list); + } + list.push(fullPath); + } } } const fragSpreads = this._fragmentsByRoot.get(root); @@ -111,46 +133,62 @@ export class RulesContext extends ValidationContext { (d) => d.name.value === fragSpread.name.value, ); if (frag) { - walk(frag, visited); + const subpath = this.subPathByNode.get(fragSpread); + const fullPath = path + subpath + ">"; + walk(frag, fullPath, visited); } } } visited.delete(root); }; - walk(operationDefinition, new Set()); + walk(operationDefinition, "", new Set()); } } } - getOperationNamesAndCoordinatesForNodes( + getErrorOperationLocationsForNodes( nodes: readonly ASTNode[] | undefined, - ): { - operationNames: readonly (string | undefined)[]; - operationCoordinates: string[]; - } { + ): ReadonlyArray { if (nodes == null) { - console.log(`No nodes!`); - return { - operationNames: [], - operationCoordinates: [], - }; + return []; } - const operationNames = new Set(); - const operationCoordinates = new Set(); + const map = new Map>(); for (const node of nodes) { const nodeOperationNames = this.operationNamesByNode.get(node); if (nodeOperationNames) { for (const operationName of nodeOperationNames) { - operationNames.add(operationName); + let set = map.get(operationName); + if (!set) { + set = new Set(); + map.set(operationName, set); + } + const op = this._operationDefinitions.find( + (o) => o.name?.value === operationName, + ); + if (op) { + const operationPathsByNode = + this.operationPathsByNodeByOperation.get(op); + if (operationPathsByNode) { + const nodeOperationCoordinates = operationPathsByNode.get(node); + if (nodeOperationCoordinates) { + for (const c of nodeOperationCoordinates) { + set.add(c); + } + } + } + } } } - const nodeOperationCoordinate = this.operationPathByNode.get(node); - if (nodeOperationCoordinate) { - operationCoordinates.add(nodeOperationCoordinate); - } } - return { - operationNames: [...operationNames], - operationCoordinates: [...operationCoordinates], - }; + const operations: Array<{ + operationName: string | undefined; + operationCoordinates: string[]; + }> = []; + for (const [operationName, operationCoordinates] of map) { + operations.push({ + operationName, + operationCoordinates: [...operationCoordinates], + }); + } + return operations; } } diff --git a/src/worker.ts b/src/worker.ts index 172c347..a93bbc9 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -99,6 +99,7 @@ async function main() { errors.push( error.toJSONEnhanced?.(rulesContext) ?? error.toJSON?.() ?? + // Ignore deprecated, this is for GraphQL v15 support formatError(error), ); } @@ -126,7 +127,12 @@ async function main() { return { sourceName, operations: [], - errors: validationErrors.map((e) => e.toJSON?.() ?? formatError(e)), + errors: validationErrors.map( + (e) => + e.toJSON?.() ?? + // Ignore deprecated, this is for GraphQL v15 support + formatError(e), + ), }; } From cdd19ceef7f3d08fa46579706bd842b10d8b1953 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 13:47:58 +0100 Subject: [PATCH 05/14] Only share the problematic operation paths --- src/DepthVisitor.ts | 51 ++++++++++++++++++++++++++++++++------------- 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/src/DepthVisitor.ts b/src/DepthVisitor.ts index 8f6a9a3..5255798 100644 --- a/src/DepthVisitor.ts +++ b/src/DepthVisitor.ts @@ -15,20 +15,21 @@ import { Kind, } from "graphql"; +import { ErrorOperationLocation } from "./interfaces.js"; import { RuleError } from "./ruleError.js"; import type { RulesContext } from "./rulesContext.js"; interface DepthInfo { current: number; max: number; - nodesByDepth: Map; + detailsByDepth: Map; } function newDepthInfo(): DepthInfo { return { current: 0, max: 0, - nodesByDepth: new Map(), + detailsByDepth: new Map(), }; } @@ -127,7 +128,7 @@ function resolveFragment( try { // Step 1: add all the fragments own depths for (const key of Object.keys(fragmentRoot.depths) as (keyof Depths)[]) { - const { max: fragMax, nodesByDepth: fragNodesByDepth } = + const { max: fragMax, detailsByDepth: fragDetailsByDepth } = fragmentRoot.depths[key]!; if (!depths[key]) { depths[key] = newDepthInfo(); @@ -136,15 +137,20 @@ function resolveFragment( if (adjustedMax > depths[key].max) { depths[key].max = adjustedMax; } - for (const [fragDepth, fragNodes] of fragNodesByDepth) { - //const transformedCoords = fragNodes.map((c) => `${operationPath}${c}`); + for (const [fragDepth, fragDetails] of fragDetailsByDepth) { + const transformedCoords = fragDetails.operationCoords.map( + (c) => `${operationPath}${c}`, + ); const depth = depths[key].current + fragDepth; - const list = depths[key].nodesByDepth.get(depth); - if (list) { - // More performant than list.push(...transformedCoords) - fragNodes.forEach((c) => list.push(c)); + const details = depths[key].detailsByDepth.get(depth); + if (details) { + // More performant than details.operationCoords.push(...transformedCoords) + transformedCoords.forEach((c) => details.operationCoords.push(c)); } else { - depths[key].nodesByDepth.set(depth, [...fragNodes]); + depths[key].detailsByDepth.set(depth, { + operationCoords: transformedCoords, + nodes: [...fragDetails.nodes], + }); } } } @@ -212,7 +218,9 @@ function resolveOperationRoot( depths[key] = newDepthInfo(); } depths[key].max = operationRoot.depths[key]!.max; - depths[key].nodesByDepth = new Map(operationRoot.depths[key]!.nodesByDepth); + depths[key].detailsByDepth = new Map( + operationRoot.depths[key]!.detailsByDepth, + ); } traverseFragmentReferences( fragmentRootByName, @@ -264,9 +272,14 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { const { current, max } = currentRoot.depths[key]; if (current > max) { currentRoot.depths[key].max = current; - currentRoot.depths[key].nodesByDepth.set(current, [node]); + currentRoot.depths[key].detailsByDepth.set(current, { + operationCoords: [context.getOperationPath()], + nodes: [node], + }); } else if (current === max) { - currentRoot.depths[key].nodesByDepth.get(current)!.push(node); + const details = currentRoot.depths[key].detailsByDepth.get(current)!; + details.operationCoords.push(context.getOperationPath()); + details.nodes.push(node); } } @@ -341,7 +354,7 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { // Now see if we've exceeded the limits for (const key of Object.keys(resolvedOperation.depths)) { - const { max, nodesByDepth } = resolvedOperation.depths[key]!; + const { max, detailsByDepth } = resolvedOperation.depths[key]!; const selfReferential = key.includes("."); const [limit, override, infraction, label] = ((): [ limit: number, @@ -402,14 +415,22 @@ export function DepthVisitor(context: RulesContext): ASTVisitor { })(); if (max > limit) { const nodes: ASTNode[] = []; + const operationCoordinates: string[] = []; for (let i = limit + 1; i <= max; i++) { - nodesByDepth.get(i)?.forEach((c) => nodes.push(c)); + const details = detailsByDepth.get(i)!; + details.nodes.forEach((c) => nodes.push(c)); + details.operationCoords.forEach((c) => + operationCoordinates.push(c), + ); } const error = new RuleError( `${label} exceeded: ${max} > ${limit}`, { infraction, nodes, + errorOperationLocations: [ + { operationName, operationCoordinates }, + ], override, }, ); From 6404219d8ddd8d3de48a4da9cb641beaa8b3ce30 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 13:49:58 +0100 Subject: [PATCH 06/14] Update test snapshots (ignore whitespace) --- __tests__/depth-limit-basics/output.ansi | 18 +-- .../output.custom-baseline.ansi | 16 +- .../results.custom-baseline.json5 | 128 +++++++++++++--- __tests__/depth-limit-basics/results.json5 | 144 ++++++++++++++---- 4 files changed, 238 insertions(+), 68 deletions(-) diff --git a/__tests__/depth-limit-basics/output.ansi b/__tests__/depth-limit-basics/output.ansi index 9e839b7..db1b954 100644 --- a/__tests__/depth-limit-basics/output.ansi +++ b/__tests__/depth-limit-basics/output.ansi @@ -1,30 +1,30 @@ FoFoF_fragments.graphql: -- Self-reference limit for field 'User.friends' exceeded: 3 > 2 +- [17:3] Self-reference limit for field 'User.friends' exceeded: 3 > 2 Problematic paths: - FoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends FoFoF.graphql: -- Self-reference limit for field 'User.friends' exceeded: 3 > 2 +- [6:9] Self-reference limit for field 'User.friends' exceeded: 3 > 2 Problematic paths: - FoFoF:query>currentUser>friends>friends>friends FoFoFoF_fragments.graphql: -- Self-reference limit for field 'User.friends' exceeded: 4 > 3 +- [22:3] Self-reference limit for field 'User.friends' exceeded: 4 > 3 Problematic paths: - FoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends FoFoFoF.graphql: -- Self-reference limit for field 'User.friends' exceeded: 4 > 3 +- [7:11] Self-reference limit for field 'User.friends' exceeded: 4 > 3 Problematic paths: - FoFoFoF:query>currentUser>friends>friends>friends>friends FoFoFoFoFoF_fragments.graphql: -- Maximum list nesting depth limit exceeded: 6 > 5 +- [35:3] Maximum list nesting depth limit exceeded: 6 > 5 Problematic paths: - FoFoFoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends>F5:User.friends>F6:User.friends FoFoFoFoFoF.graphql: -- Maximum list nesting depth limit exceeded: 6 > 5 +- [9:15] Maximum list nesting depth limit exceeded: 6 > 5 Problematic paths: - FoFoFoFoFoF:query>currentUser>friends>friends>friends>friends>friends>friends @@ -41,16 +41,16 @@ Invalid_FoFoFoFoF_wide_cycle.graphql: - [9:5] Cannot spread fragment "F1" within itself via "F2", "F3", "F4", "F5". Self10_fragments.graphql: -- Maximum selection depth limit exceeded: 11 > 10 +- [49:3] Maximum selection depth limit exceeded: 11 > 10 Problematic paths: - Self10:query>currentUser>self>F1:User.self>F2:User.self>F3:User.self>F4:User.self>F5:User.self>F6:User.self>F7:User.self>F8:User.self>F9:User.self Self10.graphql: -- Maximum selection depth limit exceeded: 11 > 10 +- [13:23] Maximum selection depth limit exceeded: 11 > 10 Problematic paths: - Self10:query>currentUser>self>self>self>self>self>self>self>self>self>self SelfOtherSelf5.graphql: -- Maximum selection depth limit exceeded: 11 > 10 +- [13:23] Maximum selection depth limit exceeded: 11 > 10 Problematic paths: - SelfOtherSelf5:query>currentUser>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf diff --git a/__tests__/depth-limit-basics/output.custom-baseline.ansi b/__tests__/depth-limit-basics/output.custom-baseline.ansi index 7a2ff9a..b53bc5b 100644 --- a/__tests__/depth-limit-basics/output.custom-baseline.ansi +++ b/__tests__/depth-limit-basics/output.custom-baseline.ansi @@ -1,25 +1,25 @@ FoFoF.graphql: -- Self-reference limit for field 'User.friends' exceeded: 3 > 2 +- [6:9] Self-reference limit for field 'User.friends' exceeded: 3 > 2 Problematic paths: - FoFoF:query>currentUser>friends>friends>friends FoFoFoF_fragments.graphql: -- Self-reference limit for field 'User.friends' exceeded: 4 > 3 +- [22:3] Self-reference limit for field 'User.friends' exceeded: 4 > 3 Problematic paths: - FoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends FoFoFoF.graphql: -- Self-reference limit for field 'User.friends' exceeded: 4 > 3 +- [7:11] Self-reference limit for field 'User.friends' exceeded: 4 > 3 Problematic paths: - FoFoFoF:query>currentUser>friends>friends>friends>friends FoFoFoFoFoF_fragments.graphql: -- Maximum list nesting depth limit exceeded: 6 > 5 +- [35:3] Maximum list nesting depth limit exceeded: 6 > 5 Problematic paths: - FoFoFoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends>F5:User.friends>F6:User.friends FoFoFoFoFoF.graphql: -- Maximum list nesting depth limit exceeded: 6 > 5 +- [9:15] Maximum list nesting depth limit exceeded: 6 > 5 Problematic paths: - FoFoFoFoFoF:query>currentUser>friends>friends>friends>friends>friends>friends @@ -36,16 +36,16 @@ Invalid_FoFoFoFoF_wide_cycle.graphql: - [9:5] Cannot spread fragment "F1" within itself via "F2", "F3", "F4", "F5". Self10_fragments.graphql: -- Maximum selection depth limit exceeded: 11 > 10 +- [49:3] Maximum selection depth limit exceeded: 11 > 10 Problematic paths: - Self10:query>currentUser>self>F1:User.self>F2:User.self>F3:User.self>F4:User.self>F5:User.self>F6:User.self>F7:User.self>F8:User.self>F9:User.self Self10.graphql: -- Maximum selection depth limit exceeded: 11 > 10 +- [13:23] Maximum selection depth limit exceeded: 11 > 10 Problematic paths: - Self10:query>currentUser>self>self>self>self>self>self>self>self>self>self SelfOtherSelf5.graphql: -- Maximum selection depth limit exceeded: 11 > 10 +- [13:23] Maximum selection depth limit exceeded: 11 > 10 Problematic paths: - SelfOtherSelf5:query>currentUser>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf diff --git a/__tests__/depth-limit-basics/results.custom-baseline.json5 b/__tests__/depth-limit-basics/results.custom-baseline.json5 index cb4dc5c..f6b8e02 100644 --- a/__tests__/depth-limit-basics/results.custom-baseline.json5 +++ b/__tests__/depth-limit-basics/results.custom-baseline.json5 @@ -29,10 +29,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 3 > 2", + locations: [ + { + line: 6, + column: 9, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoF', - operationCoordinates: [ - 'FoFoF:query>currentUser>friends>friends>friends', + operations: [ + { + operationName: 'FoFoF', + operationCoordinates: [ + 'FoFoF:query>currentUser>friends>friends>friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -55,10 +65,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 4 > 3", + locations: [ + { + line: 22, + column: 3, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoFoF', - operationCoordinates: [ - 'FoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends', + operations: [ + { + operationName: 'FoFoFoF', + operationCoordinates: [ + 'FoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -81,10 +101,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 4 > 3", + locations: [ + { + line: 7, + column: 11, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoFoF', - operationCoordinates: [ - 'FoFoFoF:query>currentUser>friends>friends>friends>friends', + operations: [ + { + operationName: 'FoFoFoF', + operationCoordinates: [ + 'FoFoFoF:query>currentUser>friends>friends>friends>friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -131,10 +161,20 @@ errors: [ { message: 'Maximum list nesting depth limit exceeded: 6 > 5', + locations: [ + { + line: 35, + column: 3, + }, + ], infraction: 'maxListDepth', - operationName: 'FoFoFoFoFoF', - operationCoordinates: [ - 'FoFoFoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends>F5:User.friends>F6:User.friends', + operations: [ + { + operationName: 'FoFoFoFoFoF', + operationCoordinates: [ + 'FoFoFoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends>F5:User.friends>F6:User.friends', + ], + }, ], override: { maxListDepth: 6, @@ -155,10 +195,20 @@ errors: [ { message: 'Maximum list nesting depth limit exceeded: 6 > 5', + locations: [ + { + line: 9, + column: 15, + }, + ], infraction: 'maxListDepth', - operationName: 'FoFoFoFoFoF', - operationCoordinates: [ - 'FoFoFoFoFoF:query>currentUser>friends>friends>friends>friends>friends>friends', + operations: [ + { + operationName: 'FoFoFoFoFoF', + operationCoordinates: [ + 'FoFoFoFoFoF:query>currentUser>friends>friends>friends>friends>friends>friends', + ], + }, ], override: { maxListDepth: 6, @@ -263,10 +313,20 @@ errors: [ { message: 'Maximum selection depth limit exceeded: 11 > 10', + locations: [ + { + line: 49, + column: 3, + }, + ], infraction: 'maxDepth', - operationName: 'Self10', - operationCoordinates: [ - 'Self10:query>currentUser>self>F1:User.self>F2:User.self>F3:User.self>F4:User.self>F5:User.self>F6:User.self>F7:User.self>F8:User.self>F9:User.self', + operations: [ + { + operationName: 'Self10', + operationCoordinates: [ + 'Self10:query>currentUser>self>F1:User.self>F2:User.self>F3:User.self>F4:User.self>F5:User.self>F6:User.self>F7:User.self>F8:User.self>F9:User.self', + ], + }, ], override: { maxDepth: 11, @@ -287,10 +347,20 @@ errors: [ { message: 'Maximum selection depth limit exceeded: 11 > 10', + locations: [ + { + line: 13, + column: 23, + }, + ], infraction: 'maxDepth', - operationName: 'Self10', - operationCoordinates: [ - 'Self10:query>currentUser>self>self>self>self>self>self>self>self>self>self', + operations: [ + { + operationName: 'Self10', + operationCoordinates: [ + 'Self10:query>currentUser>self>self>self>self>self>self>self>self>self>self', + ], + }, ], override: { maxDepth: 11, @@ -335,10 +405,20 @@ errors: [ { message: 'Maximum selection depth limit exceeded: 11 > 10', + locations: [ + { + line: 13, + column: 23, + }, + ], infraction: 'maxDepth', - operationName: 'SelfOtherSelf5', - operationCoordinates: [ - 'SelfOtherSelf5:query>currentUser>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf', + operations: [ + { + operationName: 'SelfOtherSelf5', + operationCoordinates: [ + 'SelfOtherSelf5:query>currentUser>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf', + ], + }, ], override: { maxDepth: 11, diff --git a/__tests__/depth-limit-basics/results.json5 b/__tests__/depth-limit-basics/results.json5 index f636ba9..53b5724 100644 --- a/__tests__/depth-limit-basics/results.json5 +++ b/__tests__/depth-limit-basics/results.json5 @@ -17,10 +17,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 3 > 2", + locations: [ + { + line: 17, + column: 3, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoF', - operationCoordinates: [ - 'FoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends', + operations: [ + { + operationName: 'FoFoF', + operationCoordinates: [ + 'FoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -43,10 +53,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 3 > 2", + locations: [ + { + line: 6, + column: 9, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoF', - operationCoordinates: [ - 'FoFoF:query>currentUser>friends>friends>friends', + operations: [ + { + operationName: 'FoFoF', + operationCoordinates: [ + 'FoFoF:query>currentUser>friends>friends>friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -69,10 +89,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 4 > 3", + locations: [ + { + line: 22, + column: 3, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoFoF', - operationCoordinates: [ - 'FoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends', + operations: [ + { + operationName: 'FoFoFoF', + operationCoordinates: [ + 'FoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -95,10 +125,20 @@ errors: [ { message: "Self-reference limit for field 'User.friends' exceeded: 4 > 3", + locations: [ + { + line: 7, + column: 11, + }, + ], infraction: "maxDepthByFieldCoordinates['User.friends']", - operationName: 'FoFoFoF', - operationCoordinates: [ - 'FoFoFoF:query>currentUser>friends>friends>friends>friends', + operations: [ + { + operationName: 'FoFoFoF', + operationCoordinates: [ + 'FoFoFoF:query>currentUser>friends>friends>friends>friends', + ], + }, ], override: { maxDepthByFieldCoordinates: { @@ -145,10 +185,20 @@ errors: [ { message: 'Maximum list nesting depth limit exceeded: 6 > 5', + locations: [ + { + line: 35, + column: 3, + }, + ], infraction: 'maxListDepth', - operationName: 'FoFoFoFoFoF', - operationCoordinates: [ - 'FoFoFoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends>F5:User.friends>F6:User.friends', + operations: [ + { + operationName: 'FoFoFoFoFoF', + operationCoordinates: [ + 'FoFoFoFoFoF:query>currentUser>F1:User.friends>F2:User.friends>F3:User.friends>F4:User.friends>F5:User.friends>F6:User.friends', + ], + }, ], override: { maxListDepth: 6, @@ -169,10 +219,20 @@ errors: [ { message: 'Maximum list nesting depth limit exceeded: 6 > 5', + locations: [ + { + line: 9, + column: 15, + }, + ], infraction: 'maxListDepth', - operationName: 'FoFoFoFoFoF', - operationCoordinates: [ - 'FoFoFoFoFoF:query>currentUser>friends>friends>friends>friends>friends>friends', + operations: [ + { + operationName: 'FoFoFoFoFoF', + operationCoordinates: [ + 'FoFoFoFoFoF:query>currentUser>friends>friends>friends>friends>friends>friends', + ], + }, ], override: { maxListDepth: 6, @@ -277,10 +337,20 @@ errors: [ { message: 'Maximum selection depth limit exceeded: 11 > 10', + locations: [ + { + line: 49, + column: 3, + }, + ], infraction: 'maxDepth', - operationName: 'Self10', - operationCoordinates: [ - 'Self10:query>currentUser>self>F1:User.self>F2:User.self>F3:User.self>F4:User.self>F5:User.self>F6:User.self>F7:User.self>F8:User.self>F9:User.self', + operations: [ + { + operationName: 'Self10', + operationCoordinates: [ + 'Self10:query>currentUser>self>F1:User.self>F2:User.self>F3:User.self>F4:User.self>F5:User.self>F6:User.self>F7:User.self>F8:User.self>F9:User.self', + ], + }, ], override: { maxDepth: 11, @@ -301,10 +371,20 @@ errors: [ { message: 'Maximum selection depth limit exceeded: 11 > 10', + locations: [ + { + line: 13, + column: 23, + }, + ], infraction: 'maxDepth', - operationName: 'Self10', - operationCoordinates: [ - 'Self10:query>currentUser>self>self>self>self>self>self>self>self>self>self', + operations: [ + { + operationName: 'Self10', + operationCoordinates: [ + 'Self10:query>currentUser>self>self>self>self>self>self>self>self>self>self', + ], + }, ], override: { maxDepth: 11, @@ -349,10 +429,20 @@ errors: [ { message: 'Maximum selection depth limit exceeded: 11 > 10', + locations: [ + { + line: 13, + column: 23, + }, + ], infraction: 'maxDepth', - operationName: 'SelfOtherSelf5', - operationCoordinates: [ - 'SelfOtherSelf5:query>currentUser>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf', + operations: [ + { + operationName: 'SelfOtherSelf5', + operationCoordinates: [ + 'SelfOtherSelf5:query>currentUser>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf>self>otherSelf', + ], + }, ], override: { maxDepth: 11, From a89b358d0afac5bfc2ded654c33dfc17b4e57abc Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 17:07:51 +0100 Subject: [PATCH 07/14] Export graphqlLibrary so that plugins don't need to import from graphql --- src/rulesContext.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/rulesContext.ts b/src/rulesContext.ts index 6bd8668..4380093 100644 --- a/src/rulesContext.ts +++ b/src/rulesContext.ts @@ -7,6 +7,7 @@ import type { GraphQLSchema, OperationDefinitionNode, } from "graphql"; +import * as graphqlLibrary from "graphql"; import { Kind, ValidationContext } from "graphql"; import type { ErrorOperationLocation } from "./interfaces"; @@ -14,6 +15,7 @@ import type { TypeAndOperationPathInfo } from "./operationPaths"; import type { RuleError } from "./ruleError"; export class RulesContext extends ValidationContext { + graphqlLibrary = graphqlLibrary; constructor( schema: GraphQLSchema, ast: DocumentNode, From 435985ce229611d428f41eac4e52b37c8b8886c0 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 17:08:10 +0100 Subject: [PATCH 08/14] Helpers for plugins --- src/rulesContext.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/rulesContext.ts b/src/rulesContext.ts index 4380093..2671f10 100644 --- a/src/rulesContext.ts +++ b/src/rulesContext.ts @@ -147,8 +147,14 @@ export class RulesContext extends ValidationContext { } } } + getOperationNamesForNodes(nodes: readonly ASTNode[] | undefined) { + return nodes + ? nodes.flatMap((node) => this.operationNamesByNode.get(node) ?? []) + : []; + } getErrorOperationLocationsForNodes( nodes: readonly ASTNode[] | undefined, + limitToOperations?: ReadonlyArray, ): ReadonlyArray { if (nodes == null) { return []; @@ -158,6 +164,9 @@ export class RulesContext extends ValidationContext { const nodeOperationNames = this.operationNamesByNode.get(node); if (nodeOperationNames) { for (const operationName of nodeOperationNames) { + if (limitToOperations && !limitToOperations.includes(operationName)) { + continue; + } let set = map.get(operationName); if (!set) { set = new Set(); From 5586b8c4d677a450ef8370b33bc6ce40ba913ed6 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 17:35:20 +0100 Subject: [PATCH 09/14] Fix lint --- src/DepthVisitor.ts | 1 - src/OperationPathsVisitor.ts | 2 +- src/interfaces.ts | 1 - src/worker.ts | 1 + 4 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/DepthVisitor.ts b/src/DepthVisitor.ts index 5255798..1e16187 100644 --- a/src/DepthVisitor.ts +++ b/src/DepthVisitor.ts @@ -15,7 +15,6 @@ import { Kind, } from "graphql"; -import { ErrorOperationLocation } from "./interfaces.js"; import { RuleError } from "./ruleError.js"; import type { RulesContext } from "./rulesContext.js"; diff --git a/src/OperationPathsVisitor.ts b/src/OperationPathsVisitor.ts index 22dffeb..096ca5e 100644 --- a/src/OperationPathsVisitor.ts +++ b/src/OperationPathsVisitor.ts @@ -1,4 +1,4 @@ -import { type ASTVisitor, Kind } from "graphql"; +import type { ASTVisitor } from "graphql"; import type { RulesContext } from "./rulesContext"; diff --git a/src/interfaces.ts b/src/interfaces.ts index 9aec587..c34f547 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,6 +1,5 @@ import type { ASTVisitor, GraphQLFormattedError } from "graphql"; -import { TypeAndOperationPathInfo } from "./operationPaths"; import type { RulesContext } from "./rulesContext"; export interface WorkerData { diff --git a/src/worker.ts b/src/worker.ts index a93bbc9..09363f3 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -60,6 +60,7 @@ async function main() { config.plugins, (p) => p.gqlcheck?.middleware, (name, fn, _plugin) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any middleware.register(name, fn as any); }, ); From 3283e6f95182628631e4115d64d30ec74b1be41d Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 17:47:17 +0100 Subject: [PATCH 10/14] GraphQL v15 support for new syntax --- src/worker.ts | 49 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 09363f3..aab61b7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -13,6 +13,7 @@ import { specifiedRules, validate, validateSchema, + version as graphqlVersion, visit, visitInParallel, visitWithTypeInfo, @@ -31,6 +32,8 @@ import { OperationPathsVisitor } from "./OperationPathsVisitor.js"; import type { RuleError } from "./ruleError.js"; import { RulesContext } from "./rulesContext.js"; +const graphqlVersionMajor = parseInt(graphqlVersion.split(".")[0], 10); + if (isMainThread) { throw new Error( "This script is designed to be called by `scan.ts`, but it's the main thread", @@ -111,19 +114,30 @@ async function main() { config, onError, ); - - const baseValidationRules = [ - ...specifiedRules, + const baseValidationRules = [...specifiedRules]; + const mode = + graphqlVersionMajor === 15 ? 1 : graphqlVersionMajor === 16 ? 2 : 0; + if (mode > 0) { // We need to run this so we know what the operation path/operation names are for rule errors. - () => OperationPathsVisitor(rulesContext), - ]; - const validationErrors = validate( - schema, - document, - baseValidationRules, - {}, - typeInfo, - ); + baseValidationRules.push(() => OperationPathsVisitor(rulesContext)); + } + + const validationErrors = + mode === 1 + ? // GraphQL v15 style + validate( + schema, + document, + baseValidationRules, + typeInfo as any, + {} as any, + ) + : mode === 2 + ? // GraphQL v16 style + validate(schema, document, baseValidationRules, {}, typeInfo) + : // GraphQL v17 MIGHT remove typeInfo + validate(schema, document, baseValidationRules); + if (validationErrors.length > 0) { return { sourceName, @@ -137,6 +151,17 @@ async function main() { }; } + if (mode === 0) { + // Need to revisit + visit( + document, + visitWithTypeInfo( + rulesContext.getTypeInfo(), + visitInParallel([OperationPathsVisitor(rulesContext)]), + ), + ); + } + const visitors = await middleware.run( "visitors", { rulesContext, visitors: [DepthVisitor(rulesContext)] }, From 9ee7bc5b320713feaf6ec17a6884a8084d465f54 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 18:00:10 +0100 Subject: [PATCH 11/14] Lint --- src/worker.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/worker.ts b/src/worker.ts index aab61b7..035cea9 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -129,7 +129,9 @@ async function main() { schema, document, baseValidationRules, + // eslint-disable-next-line @typescript-eslint/no-explicit-any typeInfo as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any {} as any, ) : mode === 2 From c6dc34bae02e6f44812f70ace4812d85a616220f Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 18:13:07 +0100 Subject: [PATCH 12/14] Middleware for validation --- src/index.ts | 7 ++++-- src/interfaces.ts | 19 ++++++++++++++- src/worker.ts | 62 ++++++++++++++++++++++++++++++++--------------- 3 files changed, 66 insertions(+), 22 deletions(-) diff --git a/src/index.ts b/src/index.ts index adc8e66..80a8720 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,11 @@ import type { CallbackOrDescriptor, MiddlewareNext } from "graphile-config"; -import type { ASTVisitor } from "graphql"; +import type { ASTVisitor, GraphQLError } from "graphql"; import type { CheckDocumentEvent, CheckDocumentOutput, CreateVisitorEvent, + ValidateEvent, VisitorsEvent, } from "./interfaces.js"; @@ -61,6 +62,9 @@ declare global { } interface GqlcheckMiddleware { + validate( + event: ValidateEvent, + ): PromiseOrDirect>; checkDocument( event: CheckDocumentEvent, ): PromiseLike; @@ -69,6 +73,5 @@ declare global { } } } - export type PromiseOrDirect = PromiseLike | T; export type TruePromiseOrDirect = Promise | T; diff --git a/src/interfaces.ts b/src/interfaces.ts index c34f547..25b8b12 100644 --- a/src/interfaces.ts +++ b/src/interfaces.ts @@ -1,4 +1,11 @@ -import type { ASTVisitor, GraphQLFormattedError } from "graphql"; +import type { + ASTVisitor, + DocumentNode, + GraphQLFormattedError, + GraphQLSchema, + validate, + ValidationRule, +} from "graphql"; import type { RulesContext } from "./rulesContext"; @@ -79,3 +86,13 @@ export interface ErrorOperationLocation { operationName: string | undefined; operationCoordinates: string[]; } +export interface ValidateEvent { + validate: typeof validate; + schema: GraphQLSchema; + document: DocumentNode; + rulesContext: RulesContext; + validationRules: ValidationRule[]; + options: { + maxErrors?: number; + }; +} diff --git a/src/worker.ts b/src/worker.ts index 035cea9..1dd23a0 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -114,31 +114,55 @@ async function main() { config, onError, ); - const baseValidationRules = [...specifiedRules]; + const validationRules = [...specifiedRules]; const mode = graphqlVersionMajor === 15 ? 1 : graphqlVersionMajor === 16 ? 2 : 0; if (mode > 0) { // We need to run this so we know what the operation path/operation names are for rule errors. - baseValidationRules.push(() => OperationPathsVisitor(rulesContext)); + validationRules.push(() => OperationPathsVisitor(rulesContext)); } - const validationErrors = - mode === 1 - ? // GraphQL v15 style - validate( - schema, - document, - baseValidationRules, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - typeInfo as any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - {} as any, - ) - : mode === 2 - ? // GraphQL v16 style - validate(schema, document, baseValidationRules, {}, typeInfo) - : // GraphQL v17 MIGHT remove typeInfo - validate(schema, document, baseValidationRules); + const validationErrors = await middleware.run( + "validate", + { + validate, + schema, + document, + rulesContext, + validationRules, + options: {}, + }, + ({ + validate, + schema, + document, + rulesContext, + validationRules, + options, + }) => + mode === 1 + ? // GraphQL v15 style + validate( + schema, + document, + validationRules, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + typeInfo as any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + options as any, + ) + : mode === 2 + ? // GraphQL v16 style + validate( + schema, + document, + validationRules, + options, + rulesContext.getTypeInfo(), + ) + : // GraphQL v17 MIGHT remove typeInfo + validate(schema, document, validationRules), + ); if (validationErrors.length > 0) { return { From 368e980dbb4f3ff101060f18afa939108a4879ce Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 18:13:21 +0100 Subject: [PATCH 13/14] Move adding validation rule into specific modes --- src/worker.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/worker.ts b/src/worker.ts index 1dd23a0..748bf29 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -117,10 +117,6 @@ async function main() { const validationRules = [...specifiedRules]; const mode = graphqlVersionMajor === 15 ? 1 : graphqlVersionMajor === 16 ? 2 : 0; - if (mode > 0) { - // We need to run this so we know what the operation path/operation names are for rule errors. - validationRules.push(() => OperationPathsVisitor(rulesContext)); - } const validationErrors = await middleware.run( "validate", @@ -145,7 +141,7 @@ async function main() { validate( schema, document, - validationRules, + [...validationRules, () => OperationPathsVisitor(rulesContext)], // eslint-disable-next-line @typescript-eslint/no-explicit-any typeInfo as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -156,7 +152,7 @@ async function main() { validate( schema, document, - validationRules, + [...validationRules, () => OperationPathsVisitor(rulesContext)], options, rulesContext.getTypeInfo(), ) From 8d238a6a8f0e39fa4dfb91e8da895af88abaa5d4 Mon Sep 17 00:00:00 2001 From: Benjie Gillam Date: Mon, 24 Jun 2024 18:13:53 +0100 Subject: [PATCH 14/14] Clarify --- src/worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/worker.ts b/src/worker.ts index 748bf29..23433c7 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -174,7 +174,7 @@ async function main() { } if (mode === 0) { - // Need to revisit + // Need to revisit, because OperationPathsVisitor didn't run above. visit( document, visitWithTypeInfo(