OperationValidator.java

package graphql.validation;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.Sets;
import graphql.Assert;
import graphql.Directives;
import graphql.ExperimentalApi;
import graphql.Internal;
import graphql.execution.CoercedVariables;
import graphql.execution.FieldCollector;
import graphql.execution.FieldCollectorParameters;
import graphql.execution.MergedField;
import graphql.execution.MergedSelectionSet;
import graphql.execution.TypeFromAST;
import graphql.execution.ValuesResolver;
import graphql.i18n.I18nMsg;
import graphql.introspection.Introspection.DirectiveLocation;
import graphql.language.Argument;
import graphql.language.AstComparator;
import graphql.language.BooleanValue;
import graphql.language.Definition;
import graphql.language.Directive;
import graphql.language.DirectiveDefinition;
import graphql.language.Document;
import graphql.language.Field;
import graphql.language.FragmentDefinition;
import graphql.language.FragmentSpread;
import graphql.language.InlineFragment;
import graphql.language.Node;
import graphql.language.NodeUtil;
import graphql.language.NullValue;
import graphql.language.ObjectField;
import graphql.language.ObjectValue;
import graphql.language.OperationDefinition;
import graphql.language.SchemaDefinition;
import graphql.language.Selection;
import graphql.language.SelectionSet;
import graphql.language.SourceLocation;
import graphql.language.StringValue;
import graphql.language.TypeDefinition;
import graphql.language.TypeName;
import graphql.language.Value;
import graphql.language.VariableDefinition;
import graphql.language.VariableReference;
import graphql.schema.GraphQLArgument;
import graphql.schema.GraphQLCompositeType;
import graphql.schema.GraphQLDirective;
import graphql.schema.GraphQLFieldDefinition;
import graphql.schema.GraphQLFieldsContainer;
import graphql.schema.GraphQLInputType;
import graphql.schema.GraphQLInterfaceType;
import graphql.schema.GraphQLObjectType;
import graphql.schema.GraphQLOutputType;
import graphql.schema.GraphQLType;
import graphql.schema.GraphQLTypeUtil;
import graphql.schema.GraphQLUnionType;
import graphql.schema.GraphQLUnmodifiedType;
import graphql.schema.InputValueWithState;
import graphql.util.StringKit;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Predicate;

import static graphql.collect.ImmutableKit.addToList;
import static graphql.collect.ImmutableKit.emptyList;
import static graphql.schema.GraphQLTypeUtil.isEnum;
import static graphql.schema.GraphQLTypeUtil.isInput;
import static graphql.schema.GraphQLTypeUtil.isLeaf;
import static graphql.schema.GraphQLTypeUtil.isList;
import static graphql.schema.GraphQLTypeUtil.isNonNull;
import static graphql.schema.GraphQLTypeUtil.isNotWrapped;
import static graphql.schema.GraphQLTypeUtil.isNullable;
import static graphql.schema.GraphQLTypeUtil.isScalar;
import static graphql.schema.GraphQLTypeUtil.simplePrint;
import static graphql.schema.GraphQLTypeUtil.unwrapAll;
import static graphql.schema.GraphQLTypeUtil.unwrapOne;

import static graphql.validation.ValidationError.newValidationError;
import static graphql.validation.ValidationErrorType.BadValueForDefaultArg;
import static graphql.validation.ValidationErrorType.DuplicateArgumentNames;
import static graphql.validation.ValidationErrorType.DuplicateDirectiveName;
import static graphql.validation.ValidationErrorType.DuplicateFragmentName;
import static graphql.validation.ValidationErrorType.DuplicateIncrementalLabel;
import static graphql.validation.ValidationErrorType.DuplicateOperationName;
import static graphql.validation.ValidationErrorType.DuplicateVariableName;
import static graphql.validation.ValidationErrorType.FieldUndefined;
import static graphql.validation.ValidationErrorType.FieldsConflict;
import static graphql.validation.ValidationErrorType.FragmentCycle;
import static graphql.validation.ValidationErrorType.FragmentTypeConditionInvalid;
import static graphql.validation.ValidationErrorType.InlineFragmentTypeConditionInvalid;
import static graphql.validation.ValidationErrorType.InvalidFragmentType;
import static graphql.validation.ValidationErrorType.LoneAnonymousOperationViolation;
import static graphql.validation.ValidationErrorType.MisplacedDirective;
import static graphql.validation.ValidationErrorType.MissingDirectiveArgument;
import static graphql.validation.ValidationErrorType.MissingFieldArgument;
import static graphql.validation.ValidationErrorType.NonExecutableDefinition;
import static graphql.validation.ValidationErrorType.NonInputTypeOnVariable;
import static graphql.validation.ValidationErrorType.NullValueForNonNullArgument;
import static graphql.validation.ValidationErrorType.SubselectionNotAllowed;
import static graphql.validation.ValidationErrorType.SubselectionRequired;
import static graphql.validation.ValidationErrorType.SubscriptionIntrospectionRootField;
import static graphql.validation.ValidationErrorType.SubscriptionMultipleRootFields;
import static graphql.validation.ValidationErrorType.UndefinedFragment;
import static graphql.validation.ValidationErrorType.UndefinedVariable;
import static graphql.validation.ValidationErrorType.UnknownArgument;
import static graphql.validation.ValidationErrorType.UnknownDirective;
import static graphql.validation.ValidationErrorType.UnknownOperation;
import static graphql.validation.ValidationErrorType.UnknownType;
import static graphql.validation.ValidationErrorType.UnusedFragment;
import static graphql.validation.ValidationErrorType.UnusedVariable;
import static graphql.validation.ValidationErrorType.VariableTypeMismatch;
import static graphql.validation.ValidationErrorType.WrongType;
import static java.lang.System.arraycopy;
import static graphql.language.OperationDefinition.Operation.SUBSCRIPTION;
import static graphql.validation.ValidationErrorType.UniqueObjectFieldName;

/**
 * Consolidated operation validator that implements all GraphQL validation rules
 * from the specification. Replaces the former 31 separate rule classes and the
 * RulesVisitor dispatch layer.
 *
 * <h2>Traversal Model</h2>
 *
 * <p>This validator tracks two independent state variables during traversal:
 *
 * <ul>
 *   <li><b>{@code fragmentRetraversalDepth}</b> - Tracks whether we are in the primary document
 *       traversal ({@code == 0}) or inside a manual re-traversal of a fragment via a spread
 *       ({@code > 0}).</li>
 *   <li><b>{@code operationScope}</b> - Tracks whether we are currently inside an operation
 *       definition ({@code true}) or outside of any operation ({@code false}).</li>
 * </ul>
 *
 * <h2>Traversal States</h2>
 *
 * <p>These two variables create four possible states, but only three actually occur:
 *
 * <pre>
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� State                                  ��� Description                                    ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� depth=0, operationScope=false          ��� PRIMARY TRAVERSAL, OUTSIDE OPERATION           ���
 * ���                                        ��� Visiting document root or fragment definitions ���
 * ���                                        ��� after all operations have been processed.      ���
 * ���                                        ��� Example: FragmentDefinition at document level  ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� depth=0, operationScope=true           ��� PRIMARY TRAVERSAL, INSIDE OPERATION            ���
 * ���                                        ��� Visiting nodes directly within an operation.   ���
 * ���                                        ��� Example: Field, InlineFragment in operation    ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� depth>0, operationScope=true           ��� FRAGMENT RETRAVERSAL, INSIDE OPERATION         ���
 * ���                                        ��� Manually traversing into a fragment via spread.���
 * ���                                        ��� Example: Nodes reached via ...FragmentName     ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� depth>0, operationScope=false          ��� NEVER OCCURS                                   ���
 * ���                                        ��� Retraversal only happens within an operation.  ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * </pre>
 *
 * <h2>Rule Categories</h2>
 *
 * <p>Rules are categorized by which states they should run in:
 *
 * <pre>
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� Rule Category        ��� depth=0              ��� depth=0             ��� depth>0             ���
 * ���                      ��� operationScope=false ��� operationScope=true ��� operationScope=true ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� Document-Level Rules ���         RUN          ���        RUN          ���        SKIP         ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * ��� Operation-Scoped     ���        SKIP          ���        RUN          ���        RUN          ���
 * ��� Rules                ���                      ���                     ���                     ���
 * ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * </pre>
 *
 * <h3>Document-Level Rules</h3>
 * <p>Check: {@code fragmentRetraversalDepth == 0} (via {@link #shouldRunDocumentLevelRules()})
 * <p>Purpose: Validate each AST node exactly once. Skip during fragment retraversal to avoid
 * duplicate errors (the fragment was already validated at document level).
 * <p>Examples: {@code FieldsOnCorrectType}, {@code UniqueFragmentNames}, {@code ScalarLeaves}
 *
 * <h3>Operation-Scoped Rules</h3>
 * <p>Check: {@code operationScope == true} (via {@link #shouldRunOperationScopedRules()})
 * <p>Purpose: Track state across an entire operation, including all fragments it references.
 * These rules need to "follow" fragment spreads to see variable usages, defer directives, etc.
 * <p>Examples: {@code NoUndefinedVariables}, {@code NoUnusedVariables}, {@code VariableTypesMatch}
 *
 * <h2>Traversal Example</h2>
 *
 * <p>Consider this GraphQL document:
 * <pre>{@code
 * query GetUser($id: ID!) {
 *   user(id: $id) {
 *     ...UserFields
 *   }
 * }
 *
 * fragment UserFields on User {
 *   name
 *   friends {
 *     ...UserFields   # recursive spread
 *   }
 * }
 * }</pre>
 *
 * <p>The traversal proceeds as follows:
 *
 * <pre>
 * STEP  NODE                        depth  operationScope  DOC-LEVEL  OP-SCOPED
 * ������������  ������������������������������������������������������������������������������  ���������������  ������������������������������������������  ���������������������������  ���������������������������
 *  1    Document                      0        false          RUN       SKIP
 *  2    OperationDefinition           0        true           RUN       RUN
 *  3    ������ VariableDefinition $id     0        true           RUN       RUN
 *  4    ������ Field "user"               0        true           RUN       RUN
 *  5    ���  ������ FragmentSpread          0        true           RUN       RUN
 *       ���     ...UserFields
 *       ���     ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 *       ���     ��� MANUAL RETRAVERSAL INTO FRAGMENT                            ���
 *       ���     ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 *  6    ���     FragmentDefinition      1        true          SKIP       RUN
 *  7    ���     ������ Field "name"         1        true          SKIP       RUN
 *  8    ���     ������ Field "friends"      1        true          SKIP       RUN
 *  9    ���     ���  ������ FragmentSpread    1        true          SKIP       RUN
 *       ���     ���     ...UserFields
 *       ���     ���     (already visited - skip to avoid infinite loop)
 *       ���     ���������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������������
 * 10    ������ (leave OperationDef)       0        false      [finalize op-scoped rules]
 * 11    FragmentDefinition            0        false          RUN       SKIP
 *       "UserFields" (at doc level)
 * 12    ������ Field "name"               0        false          RUN       SKIP
 * 13    ������ Field "friends"            0        false          RUN       SKIP
 * 14    ���  ������ FragmentSpread          0        false          RUN       SKIP
 * </pre>
 *
 * <h2>Key Observations</h2>
 *
 * <ul>
 *   <li><b>Steps 6-9:</b> During retraversal, document-level rules SKIP because the fragment
 *       will be validated at steps 11-14. This prevents duplicate "field not found" errors.</li>
 *   <li><b>Steps 6-9:</b> Operation-scoped rules RUN to track that variables used inside
 *       {@code UserFields} are defined in the operation.</li>
 *   <li><b>Steps 11-14:</b> Operation-scoped rules SKIP because there's no operation context
 *       to track variables against.</li>
 *   <li><b>Step 9:</b> Recursive fragment spreads are tracked via {@code visitedFragmentSpreads}
 *       to prevent infinite loops during retraversal.</li>
 * </ul>
 *
 * @see OperationValidationRule
 */
@Internal
@NullMarked
@SuppressWarnings("rawtypes")
public class OperationValidator implements DocumentVisitor {

    // --- Infrastructure ---
    private final ValidationContext validationContext;
    private final ValidationErrorCollector errorCollector;
    private final ValidationUtil validationUtil;
    private final Predicate<OperationValidationRule> rulePredicate;

    // --- Traversal context ---
    /**
     * True when currently processing within an operation definition.
     */
    private boolean operationScope = false;
    /**
     * Depth of manual fragment traversal; 0 means primary document traversal.
     */
    private int fragmentRetraversalDepth = 0;
    /**
     * Tracks which fragments have been traversed via spreads to avoid infinite loops.
     */
    private final Set<String> visitedFragmentSpreads = new HashSet<>();

    // --- State: NoFragmentCycles ---
    private final Map<String, Set<String>> fragmentSpreadsMap = new HashMap<>();

    // --- State: NoUnusedFragments ---
    private final List<FragmentDefinition> allDeclaredFragments = new ArrayList<>();
    private List<String> unusedFragTracking_usedFragments = new ArrayList<>();
    private final Map<String, List<String>> spreadsInDefinition = new LinkedHashMap<>();
    private final List<List<String>> fragmentsUsedDirectlyInOperation = new ArrayList<>();

    // --- State: NoUndefinedVariables ---
    private final Set<String> definedVariableNames = new LinkedHashSet<>();

    // --- State: NoUnusedVariables ---
    private final List<VariableDefinition> unusedVars_variableDefinitions = new ArrayList<>();
    private final Set<String> unusedVars_usedVariables = new LinkedHashSet<>();

    // --- State: VariableTypesMatch ---
    private final VariablesTypesMatcher variablesTypesMatcher = new VariablesTypesMatcher();
    private @Nullable Map<String, VariableDefinition> variableDefinitionMap;

    // --- State: OverlappingFieldsCanBeMerged ---
    private final Set<Set<FieldAndType>> sameResponseShapeChecked = new LinkedHashSet<>();
    private final Set<Set<FieldAndType>> sameForCommonParentsChecked = new LinkedHashSet<>();
    private final Set<Set<Field>> conflictsReported = new LinkedHashSet<>();

    // --- State: LoneAnonymousOperation ---
    private boolean hasAnonymousOp = false;
    private int loneAnon_count = 0;

    // --- State: UniqueOperationNames ---
    private final Set<String> operationNames = new LinkedHashSet<>();

    // --- State: UniqueFragmentNames ---
    private final Set<String> fragmentNames = new LinkedHashSet<>();

    // --- State: DeferDirectiveLabel ---
    private final Set<String> checkedDeferLabels = new LinkedHashSet<>();

    // --- State: SubscriptionUniqueRootField ---
    private final FieldCollector fieldCollector = new FieldCollector();

    // --- Track whether we're in a context where fragment spread rules should run ---
    // fragmentRetraversalDepth == 0 means we're NOT inside a manually-traversed fragment => run non-fragment-spread checks
    // operationScope means we're inside an operation => can trigger fragment traversal

    private final boolean allRulesEnabled;

    public OperationValidator(ValidationContext validationContext, ValidationErrorCollector errorCollector, Predicate<OperationValidationRule> rulePredicate) {
        this.validationContext = validationContext;
        this.errorCollector = errorCollector;
        this.validationUtil = new ValidationUtil();
        this.rulePredicate = rulePredicate;
        this.allRulesEnabled = detectAllRulesEnabled(rulePredicate);
        prepareFragmentSpreadsMap();
    }

    private static boolean detectAllRulesEnabled(Predicate<OperationValidationRule> predicate) {
        for (OperationValidationRule rule : OperationValidationRule.values()) {
            if (!predicate.test(rule)) {
                return false;
            }
        }
        return true;
    }

    private boolean isRuleEnabled(OperationValidationRule rule) {
        return allRulesEnabled || rulePredicate.test(rule);
    }

    /**
     * Returns true when document-level rules should run.
     *
     * <p>Document-level rules validate each AST node exactly once during the primary
     * document traversal. They do NOT re-run when fragments are traversed through
     * spreads, which prevents duplicate validation errors.
     *
     * <p>Examples: {@code FieldsOnCorrectType}, {@code UniqueFragmentNames},
     * {@code ScalarLeaves}, {@code KnownDirectives}.
     *
     * @return true if {@code fragmentRetraversalDepth == 0} (primary traversal)
     */
    private boolean shouldRunDocumentLevelRules() {
        return fragmentRetraversalDepth == 0;
    }

    /**
     * Returns true when operation-scoped rules should run.
     *
     * <p>Operation-scoped rules must follow fragment spreads to see the complete
     * picture of an operation. They track state across all code paths, including
     * fragments referenced by the operation.
     *
     * <p>Examples: {@code NoUndefinedVariables}, {@code NoUnusedVariables},
     * {@code VariableTypesMatch}, {@code DeferDirectiveOnRootLevel}.
     *
     * @return true if currently processing within an operation scope
     */
    private boolean shouldRunOperationScopedRules() {
        return operationScope;
    }

    @Override
    public void enter(Node node, List<Node> ancestors) {
        validationContext.getTraversalContext().enter(node, ancestors);

        if (node instanceof Document) {
            checkDocument((Document) node);
        } else if (node instanceof Argument) {
            checkArgument((Argument) node);
        } else if (node instanceof TypeName) {
            checkTypeName((TypeName) node);
        } else if (node instanceof VariableDefinition) {
            checkVariableDefinition((VariableDefinition) node);
        } else if (node instanceof Field) {
            checkField((Field) node);
        } else if (node instanceof InlineFragment) {
            checkInlineFragment((InlineFragment) node);
        } else if (node instanceof Directive) {
            checkDirective((Directive) node, ancestors);
        } else if (node instanceof FragmentSpread) {
            checkFragmentSpread((FragmentSpread) node, ancestors);
        } else if (node instanceof FragmentDefinition) {
            checkFragmentDefinition((FragmentDefinition) node);
        } else if (node instanceof OperationDefinition) {
            checkOperationDefinition((OperationDefinition) node);
        } else if (node instanceof VariableReference) {
            checkVariable((VariableReference) node);
        } else if (node instanceof SelectionSet) {
            checkSelectionSet();
        } else if (node instanceof ObjectValue) {
            checkObjectValue((ObjectValue) node);
        }
    }

    @Override
    public void leave(Node node, List<Node> ancestors) {
        validationContext.getTraversalContext().leave(node, ancestors);

        if (node instanceof Document) {
            documentFinished();
        } else if (node instanceof OperationDefinition) {
            leaveOperationDefinition();
        } else if (node instanceof SelectionSet) {
            leaveSelectionSet();
        } else if (node instanceof FragmentDefinition) {
            leaveFragmentDefinition();
        }
    }

    private void addError(ValidationErrorType validationErrorType, Collection<? extends Node<?>> locations, String description) {
        List<SourceLocation> locationList = new ArrayList<>();
        for (Node<?> node : locations) {
            SourceLocation sourceLocation = node.getSourceLocation();
            if (sourceLocation != null) {
                locationList.add(sourceLocation);
            }
        }
        addError(newValidationError()
                .validationErrorType(validationErrorType)
                .sourceLocations(locationList)
                .description(description));
    }

    private void addError(ValidationErrorType validationErrorType, @Nullable SourceLocation location, String description) {
        addError(newValidationError()
                .validationErrorType(validationErrorType)
                .sourceLocation(location)
                .description(description));
    }

    private void addError(ValidationError.Builder validationError) {
        errorCollector.addError(validationError.queryPath(getQueryPath()).build());
    }

    private @Nullable List<String> getQueryPath() {
        return validationContext.getQueryPath();
    }

    private String i18n(ValidationErrorType validationErrorType, I18nMsg i18nMsg) {
        return i18n(validationErrorType, i18nMsg.getMsgKey(), i18nMsg.getMsgArguments());
    }

    private String i18n(ValidationErrorType validationErrorType, String msgKey, Object... msgArgs) {
        Object[] params = new Object[msgArgs.length + 1];
        params[0] = mkTypeAndPath(validationErrorType);
        arraycopy(msgArgs, 0, params, 1, msgArgs.length);
        return validationContext.i18n(msgKey, params);
    }

    private String mkTypeAndPath(ValidationErrorType validationErrorType) {
        List<String> queryPath = getQueryPath();
        StringBuilder sb = new StringBuilder();
        sb.append(validationErrorType);
        if (queryPath != null) {
            sb.append("@[").append(String.join("/", queryPath)).append("]");
        }
        return sb.toString();
    }

    private boolean isExperimentalApiKeyEnabled(String key) {
        Object value = validationContext.getGraphQLContext().get(key);
        return value instanceof Boolean && (Boolean) value;
    }

    private void checkDocument(Document document) {
        if (isRuleEnabled(OperationValidationRule.EXECUTABLE_DEFINITIONS)) {
            validateExecutableDefinitions(document);
        }
    }

    private void checkArgument(Argument argument) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.ARGUMENTS_OF_CORRECT_TYPE)) {
                validateArgumentsOfCorrectType(argument);
            }
            if (isRuleEnabled(OperationValidationRule.KNOWN_ARGUMENT_NAMES)) {
                validateKnownArgumentNames(argument);
            }
        }
    }

    private void checkTypeName(TypeName typeName) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.KNOWN_TYPE_NAMES)) {
                validateKnownTypeNames(typeName);
            }
        }
    }

    private void checkVariableDefinition(VariableDefinition variableDefinition) {
        if (isRuleEnabled(OperationValidationRule.VARIABLE_DEFAULT_VALUES_OF_CORRECT_TYPE)) {
            validateVariableDefaultValuesOfCorrectType(variableDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.VARIABLES_ARE_INPUT_TYPES)) {
            validateVariablesAreInputTypes(variableDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.NO_UNDEFINED_VARIABLES)) {
            definedVariableNames.add(variableDefinition.getName());
        }
        if (isRuleEnabled(OperationValidationRule.NO_UNUSED_VARIABLES)) {
            unusedVars_variableDefinitions.add(variableDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.VARIABLE_TYPES_MATCH)) {
            if (variableDefinitionMap != null) {
                variableDefinitionMap.put(variableDefinition.getName(), variableDefinition);
            }
        }
    }

    private void checkField(Field field) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.FIELDS_ON_CORRECT_TYPE)) {
                validateFieldsOnCorrectType(field);
            }
            if (isRuleEnabled(OperationValidationRule.SCALAR_LEAVES)) {
                validateScalarLeaves(field);
            }
            if (isRuleEnabled(OperationValidationRule.PROVIDED_NON_NULL_ARGUMENTS)) {
                validateProvidedNonNullArguments_field(field);
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_ARGUMENT_NAMES)) {
                validateUniqueArgumentNames_field(field);
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_DIRECTIVE_NAMES_PER_LOCATION)) {
                validateUniqueDirectiveNamesPerLocation(field, field.getDirectives());
            }
        }
    }

    private void checkInlineFragment(InlineFragment inlineFragment) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.FRAGMENTS_ON_COMPOSITE_TYPE)) {
                validateFragmentsOnCompositeType_inline(inlineFragment);
            }
            if (isRuleEnabled(OperationValidationRule.POSSIBLE_FRAGMENT_SPREADS)) {
                validatePossibleFragmentSpreads_inline(inlineFragment);
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_DIRECTIVE_NAMES_PER_LOCATION)) {
                validateUniqueDirectiveNamesPerLocation(inlineFragment, inlineFragment.getDirectives());
            }
        }
    }

    private void checkDirective(Directive directive, List<Node> ancestors) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.KNOWN_DIRECTIVES)) {
                validateKnownDirectives(directive, ancestors);
            }
            if (isRuleEnabled(OperationValidationRule.PROVIDED_NON_NULL_ARGUMENTS)) {
                validateProvidedNonNullArguments_directive(directive);
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_ARGUMENT_NAMES)) {
                validateUniqueArgumentNames_directive(directive);
            }
            if (isRuleEnabled(OperationValidationRule.DEFER_DIRECTIVE_LABEL)) {
                validateDeferDirectiveLabel(directive);
            }
        }
        if (shouldRunOperationScopedRules()) {
            if (isRuleEnabled(OperationValidationRule.DEFER_DIRECTIVE_ON_ROOT_LEVEL)) {
                validateDeferDirectiveOnRootLevel(directive);
            }
            if (isRuleEnabled(OperationValidationRule.DEFER_DIRECTIVE_ON_VALID_OPERATION)) {
                validateDeferDirectiveOnValidOperation(directive, ancestors);
            }
        }
    }

    private void checkFragmentSpread(FragmentSpread node, List<Node> ancestors) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.KNOWN_FRAGMENT_NAMES)) {
                validateKnownFragmentNames(node);
            }
            if (isRuleEnabled(OperationValidationRule.POSSIBLE_FRAGMENT_SPREADS)) {
                validatePossibleFragmentSpreads_spread(node);
            }
            if (isRuleEnabled(OperationValidationRule.NO_UNUSED_FRAGMENTS)) {
                unusedFragTracking_usedFragments.add(node.getName());
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_DIRECTIVE_NAMES_PER_LOCATION)) {
                validateUniqueDirectiveNamesPerLocation(node, node.getDirectives());
            }
        }

        // Manually traverse into fragment definition during operation scope
        if (operationScope) {
            FragmentDefinition fragment = validationContext.getFragment(node.getName());
            if (fragment != null && !visitedFragmentSpreads.contains(node.getName())) {
                visitedFragmentSpreads.add(node.getName());
                fragmentRetraversalDepth++;
                new LanguageTraversal(ancestors).traverse(fragment, this);
                fragmentRetraversalDepth--;
            }
        }
    }

    private void checkFragmentDefinition(FragmentDefinition fragmentDefinition) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.FRAGMENTS_ON_COMPOSITE_TYPE)) {
                validateFragmentsOnCompositeType_definition(fragmentDefinition);
            }
            if (isRuleEnabled(OperationValidationRule.NO_FRAGMENT_CYCLES)) {
                validateNoFragmentCycles(fragmentDefinition);
            }
            if (isRuleEnabled(OperationValidationRule.NO_UNUSED_FRAGMENTS)) {
                allDeclaredFragments.add(fragmentDefinition);
                unusedFragTracking_usedFragments = new ArrayList<>();
                spreadsInDefinition.put(fragmentDefinition.getName(), unusedFragTracking_usedFragments);
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_FRAGMENT_NAMES)) {
                validateUniqueFragmentNames(fragmentDefinition);
            }
            if (isRuleEnabled(OperationValidationRule.UNIQUE_DIRECTIVE_NAMES_PER_LOCATION)) {
                validateUniqueDirectiveNamesPerLocation(fragmentDefinition, fragmentDefinition.getDirectives());
            }
        }
    }

    private void checkOperationDefinition(OperationDefinition operationDefinition) {
        operationScope = true;

        if (isRuleEnabled(OperationValidationRule.OVERLAPPING_FIELDS_CAN_BE_MERGED)) {
            validateOverlappingFieldsCanBeMerged(operationDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.LONE_ANONYMOUS_OPERATION)) {
            validateLoneAnonymousOperation(operationDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.UNIQUE_OPERATION_NAMES)) {
            validateUniqueOperationNames(operationDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.UNIQUE_VARIABLE_NAMES)) {
            validateUniqueVariableNames(operationDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.SUBSCRIPTION_UNIQUE_ROOT_FIELD)) {
            validateSubscriptionUniqueRootField(operationDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.UNIQUE_DIRECTIVE_NAMES_PER_LOCATION)) {
            validateUniqueDirectiveNamesPerLocation(operationDefinition, operationDefinition.getDirectives());
        }
        if (isRuleEnabled(OperationValidationRule.KNOWN_OPERATION_TYPES)) {
            validateKnownOperationTypes(operationDefinition);
        }
        if (isRuleEnabled(OperationValidationRule.NO_UNUSED_FRAGMENTS)) {
            unusedFragTracking_usedFragments = new ArrayList<>();
            fragmentsUsedDirectlyInOperation.add(unusedFragTracking_usedFragments);
        }
        if (isRuleEnabled(OperationValidationRule.NO_UNDEFINED_VARIABLES)) {
            definedVariableNames.clear();
        }
        if (isRuleEnabled(OperationValidationRule.NO_UNUSED_VARIABLES)) {
            unusedVars_usedVariables.clear();
            unusedVars_variableDefinitions.clear();
        }
        if (isRuleEnabled(OperationValidationRule.VARIABLE_TYPES_MATCH)) {
            variableDefinitionMap = new LinkedHashMap<>();
        }
    }

    private void checkVariable(VariableReference variableReference) {
        if (shouldRunOperationScopedRules()) {
            if (isRuleEnabled(OperationValidationRule.NO_UNDEFINED_VARIABLES)) {
                validateNoUndefinedVariables(variableReference);
            }
            if (isRuleEnabled(OperationValidationRule.VARIABLE_TYPES_MATCH)) {
                validateVariableTypesMatch(variableReference);
            }
            if (isRuleEnabled(OperationValidationRule.NO_UNUSED_VARIABLES)) {
                unusedVars_usedVariables.add(variableReference.getName());
            }
        }
    }

    private void checkSelectionSet() {
        // No rules currently check selection set on enter
    }

    private void checkObjectValue(ObjectValue objectValue) {
        if (shouldRunDocumentLevelRules()) {
            if (isRuleEnabled(OperationValidationRule.UNIQUE_OBJECT_FIELD_NAME)) {
                validateUniqueObjectFieldName(objectValue);
            }
        }
    }

    private void leaveOperationDefinition() {
        // fragments should be revisited for each operation
        visitedFragmentSpreads.clear();
        operationScope = false;

        if (isRuleEnabled(OperationValidationRule.NO_UNUSED_VARIABLES)) {
            for (VariableDefinition variableDefinition : unusedVars_variableDefinitions) {
                if (!unusedVars_usedVariables.contains(variableDefinition.getName())) {
                    String message = i18n(UnusedVariable, "NoUnusedVariables.unusedVariable", variableDefinition.getName());
                    addError(UnusedVariable, variableDefinition.getSourceLocation(), message);
                }
            }
        }
    }

    private void leaveSelectionSet() {
        // No rules currently use leaveSelectionSet
    }

    private void leaveFragmentDefinition() {
        // No special handling needed - the fragment spread depth tracking
        // is handled in checkFragmentSpread
    }

    private void documentFinished() {
        if (isRuleEnabled(OperationValidationRule.NO_UNUSED_FRAGMENTS)) {
            validateNoUnusedFragments();
        }
        if (isRuleEnabled(OperationValidationRule.LONE_ANONYMOUS_OPERATION)) {
            hasAnonymousOp = false;
        }
    }

    // --- ExecutableDefinitions ---
    private void validateExecutableDefinitions(Document document) {
        document.getDefinitions().forEach(definition -> {
            if (!(definition instanceof OperationDefinition)
                    && !(definition instanceof FragmentDefinition)) {
                String message = nonExecutableDefinitionMessage(definition);
                addError(NonExecutableDefinition, definition.getSourceLocation(), message);
            }
        });
    }

    private String nonExecutableDefinitionMessage(Definition definition) {
        if (definition instanceof TypeDefinition) {
            return i18n(NonExecutableDefinition, "ExecutableDefinitions.notExecutableType", ((TypeDefinition) definition).getName());
        } else if (definition instanceof SchemaDefinition) {
            return i18n(NonExecutableDefinition, "ExecutableDefinitions.notExecutableSchema");
        } else if (definition instanceof DirectiveDefinition) {
            return i18n(NonExecutableDefinition, "ExecutableDefinitions.notExecutableDirective", ((DirectiveDefinition) definition).getName());
        }
        return i18n(NonExecutableDefinition, "ExecutableDefinitions.notExecutableDefinition");
    }

    // --- ArgumentsOfCorrectType ---
    private void validateArgumentsOfCorrectType(Argument argument) {
        GraphQLArgument fieldArgument = validationContext.getArgument();
        if (fieldArgument == null) {
            return;
        }
        ArgumentValidationUtil argValidationUtil = new ArgumentValidationUtil(argument);
        if (!argValidationUtil.isValidLiteralValue(argument.getValue(), fieldArgument.getType(),
                validationContext.getSchema(), validationContext.getGraphQLContext(), validationContext.getI18n().getLocale())) {
            String message = i18n(WrongType, argValidationUtil.getMsgAndArgs());
            addError(newValidationError()
                    .validationErrorType(WrongType)
                    .sourceLocation(argument.getSourceLocation())
                    .description(message)
                    .extensions(argValidationUtil.getErrorExtensions()));
        }
    }

    // --- FieldsOnCorrectType ---
    private void validateFieldsOnCorrectType(Field field) {
        GraphQLCompositeType parentType = validationContext.getParentType();
        if (parentType == null) {
            return;
        }
        GraphQLFieldDefinition fieldDef = validationContext.getFieldDef();
        if (fieldDef == null) {
            String message = i18n(FieldUndefined, "FieldsOnCorrectType.unknownField", field.getName(), parentType.getName());
            addError(FieldUndefined, field.getSourceLocation(), message);
        }
    }

    // --- FragmentsOnCompositeType ---
    private void validateFragmentsOnCompositeType_inline(InlineFragment inlineFragment) {
        if (inlineFragment.getTypeCondition() == null) {
            return;
        }
        GraphQLType type = validationContext.getSchema().getType(inlineFragment.getTypeCondition().getName());
        if (type == null) {
            return;
        }
        if (!(type instanceof GraphQLCompositeType)) {
            String message = i18n(InlineFragmentTypeConditionInvalid, "FragmentsOnCompositeType.invalidInlineTypeCondition");
            addError(InlineFragmentTypeConditionInvalid, inlineFragment.getSourceLocation(), message);
        }
    }

    private void validateFragmentsOnCompositeType_definition(FragmentDefinition fragmentDefinition) {
        GraphQLType type = validationContext.getSchema().getType(fragmentDefinition.getTypeCondition().getName());
        if (type == null) {
            return;
        }
        if (!(type instanceof GraphQLCompositeType)) {
            String message = i18n(FragmentTypeConditionInvalid, "FragmentsOnCompositeType.invalidFragmentTypeCondition");
            addError(FragmentTypeConditionInvalid, fragmentDefinition.getSourceLocation(), message);
        }
    }

    // --- KnownArgumentNames ---
    private void validateKnownArgumentNames(Argument argument) {
        GraphQLDirective directiveDef = validationContext.getDirective();
        if (directiveDef != null) {
            GraphQLArgument directiveArgument = directiveDef.getArgument(argument.getName());
            if (directiveArgument == null) {
                String message = i18n(UnknownDirective, "KnownArgumentNames.unknownDirectiveArg", argument.getName());
                addError(UnknownDirective, argument.getSourceLocation(), message);
            }
            return;
        }
        GraphQLFieldDefinition fieldDef = validationContext.getFieldDef();
        if (fieldDef == null) {
            return;
        }
        GraphQLArgument fieldArgument = fieldDef.getArgument(argument.getName());
        if (fieldArgument == null) {
            String message = i18n(UnknownArgument, "KnownArgumentNames.unknownFieldArg", argument.getName());
            addError(UnknownArgument, argument.getSourceLocation(), message);
        }
    }

    // --- KnownDirectives ---
    private void validateKnownDirectives(Directive directive, List<Node> ancestors) {
        GraphQLDirective graphQLDirective = validationContext.getSchema().getDirective(directive.getName());
        if (graphQLDirective == null) {
            String message = i18n(UnknownDirective, "KnownDirectives.unknownDirective", directive.getName());
            addError(UnknownDirective, directive.getSourceLocation(), message);
            return;
        }
        Node ancestor = ancestors.get(ancestors.size() - 1);
        if (hasInvalidLocation(graphQLDirective, ancestor)) {
            String message = i18n(MisplacedDirective, "KnownDirectives.directiveNotAllowed", directive.getName());
            addError(MisplacedDirective, directive.getSourceLocation(), message);
        }
    }

    private boolean hasInvalidLocation(GraphQLDirective directive, Node ancestor) {
        EnumSet<DirectiveLocation> validLocations = directive.validLocations();
        if (ancestor instanceof OperationDefinition) {
            OperationDefinition.Operation operation = ((OperationDefinition) ancestor).getOperation();
            if (OperationDefinition.Operation.QUERY.equals(operation)) {
                return !validLocations.contains(DirectiveLocation.QUERY);
            } else if (OperationDefinition.Operation.MUTATION.equals(operation)) {
                return !validLocations.contains(DirectiveLocation.MUTATION);
            } else if (OperationDefinition.Operation.SUBSCRIPTION.equals(operation)) {
                return !validLocations.contains(DirectiveLocation.SUBSCRIPTION);
            }
        } else if (ancestor instanceof Field) {
            return !(validLocations.contains(DirectiveLocation.FIELD));
        } else if (ancestor instanceof FragmentSpread) {
            return !(validLocations.contains(DirectiveLocation.FRAGMENT_SPREAD));
        } else if (ancestor instanceof FragmentDefinition) {
            return !(validLocations.contains(DirectiveLocation.FRAGMENT_DEFINITION));
        } else if (ancestor instanceof InlineFragment) {
            return !(validLocations.contains(DirectiveLocation.INLINE_FRAGMENT));
        } else if (ancestor instanceof VariableDefinition) {
            return !(validLocations.contains(DirectiveLocation.VARIABLE_DEFINITION));
        }
        return true;
    }

    // --- KnownFragmentNames ---
    private void validateKnownFragmentNames(FragmentSpread fragmentSpread) {
        FragmentDefinition fragmentDefinition = validationContext.getFragment(fragmentSpread.getName());
        if (fragmentDefinition == null) {
            String message = i18n(UndefinedFragment, "KnownFragmentNames.undefinedFragment", fragmentSpread.getName());
            addError(UndefinedFragment, fragmentSpread.getSourceLocation(), message);
        }
    }

    // --- KnownTypeNames ---
    private void validateKnownTypeNames(TypeName typeName) {
        if (validationContext.getSchema().getType(typeName.getName()) == null) {
            String message = i18n(UnknownType, "KnownTypeNames.unknownType", typeName.getName());
            addError(UnknownType, typeName.getSourceLocation(), message);
        }
    }

    // --- NoFragmentCycles ---
    private void prepareFragmentSpreadsMap() {
        List<Definition> definitions = validationContext.getDocument().getDefinitions();
        for (Definition definition : definitions) {
            if (definition instanceof FragmentDefinition) {
                FragmentDefinition fragmentDefinition = (FragmentDefinition) definition;
                fragmentSpreadsMap.put(fragmentDefinition.getName(), gatherSpreads(fragmentDefinition));
            }
        }
    }

    private Set<String> gatherSpreads(FragmentDefinition fragmentDefinition) {
        final Set<String> spreads = new HashSet<>();
        DocumentVisitor visitor = new DocumentVisitor() {
            @Override
            public void enter(Node node, List<Node> path) {
                if (node instanceof FragmentSpread) {
                    spreads.add(((FragmentSpread) node).getName());
                }
            }

            @Override
            public void leave(Node node, List<Node> path) {
            }
        };
        new LanguageTraversal().traverse(fragmentDefinition, visitor);
        return spreads;
    }

    private void validateNoFragmentCycles(FragmentDefinition fragmentDefinition) {
        ArrayList<String> path = new ArrayList<>();
        path.add(fragmentDefinition.getName());
        Map<String, Set<String>> transitiveSpreads = buildTransitiveSpreads(path, new HashMap<>());

        for (Map.Entry<String, Set<String>> entry : transitiveSpreads.entrySet()) {
            if (entry.getValue().contains(entry.getKey())) {
                String message = i18n(FragmentCycle, "NoFragmentCycles.cyclesNotAllowed");
                addError(FragmentCycle, Collections.singletonList(fragmentDefinition), message);
            }
        }
    }

    private Map<String, Set<String>> buildTransitiveSpreads(ArrayList<String> path, Map<String, Set<String>> transitiveSpreads) {
        String name = path.get(path.size() - 1);
        if (transitiveSpreads.containsKey(name)) {
            return transitiveSpreads;
        }
        Set<String> spreads = fragmentSpreadsMap.get(name);
        if (spreads == null || spreads.isEmpty()) {
            return transitiveSpreads;
        }
        for (String ancestor : path) {
            Set<String> ancestorSpreads = transitiveSpreads.get(ancestor);
            if (ancestorSpreads == null) {
                ancestorSpreads = new HashSet<>();
            }
            ancestorSpreads.addAll(spreads);
            transitiveSpreads.put(ancestor, ancestorSpreads);
        }
        for (String child : spreads) {
            if (path.contains(child) || transitiveSpreads.containsKey(child)) {
                continue;
            }
            ArrayList<String> childPath = new ArrayList<>(path);
            childPath.add(child);
            buildTransitiveSpreads(childPath, transitiveSpreads);
        }
        return transitiveSpreads;
    }

    // --- NoUndefinedVariables ---
    private void validateNoUndefinedVariables(VariableReference variableReference) {
        if (!definedVariableNames.contains(variableReference.getName())) {
            String message = i18n(UndefinedVariable, "NoUndefinedVariables.undefinedVariable", variableReference.getName());
            addError(UndefinedVariable, variableReference.getSourceLocation(), message);
        }
    }

    // --- NoUnusedFragments ---
    private void validateNoUnusedFragments() {
        Set<String> allUsedFragments = new HashSet<>();
        for (List<String> fragmentsInOneOperation : fragmentsUsedDirectlyInOperation) {
            for (String fragment : fragmentsInOneOperation) {
                collectUsedFragmentsInDefinition(allUsedFragments, fragment);
            }
        }
        for (FragmentDefinition fragmentDefinition : allDeclaredFragments) {
            if (!allUsedFragments.contains(fragmentDefinition.getName())) {
                String message = i18n(UnusedFragment, "NoUnusedFragments.unusedFragments", fragmentDefinition.getName());
                addError(UnusedFragment, fragmentDefinition.getSourceLocation(), message);
            }
        }
    }

    private void collectUsedFragmentsInDefinition(Set<String> result, String fragmentName) {
        if (!result.add(fragmentName)) {
            return;
        }
        List<String> spreadList = spreadsInDefinition.get(fragmentName);
        if (spreadList == null) {
            return;
        }
        for (String fragment : spreadList) {
            collectUsedFragmentsInDefinition(result, fragment);
        }
    }

    // --- OverlappingFieldsCanBeMerged ---
    private void validateOverlappingFieldsCanBeMerged(OperationDefinition operationDefinition) {
        overlappingFieldsImpl(operationDefinition.getSelectionSet(), validationContext.getOutputType());
    }

    private void overlappingFieldsImpl(SelectionSet selectionSet, @Nullable GraphQLOutputType graphQLOutputType) {
        Map<String, Set<FieldAndType>> fieldMap = new LinkedHashMap<>(selectionSet.getSelections().size());
        Set<String> visitedFragments = new LinkedHashSet<>();
        overlappingFields_collectFields(fieldMap, selectionSet, graphQLOutputType, visitedFragments);
        List<Conflict> conflicts = findConflicts(fieldMap);
        for (Conflict conflict : conflicts) {
            if (conflictsReported.contains(conflict.fields)) {
                continue;
            }
            conflictsReported.add(conflict.fields);
            addError(FieldsConflict, conflict.fields, conflict.reason);
        }
    }

    private void overlappingFields_collectFields(Map<String, Set<FieldAndType>> fieldMap, SelectionSet selectionSet, @Nullable GraphQLType parentType, Set<String> visitedFragments) {
        for (Selection selection : selectionSet.getSelections()) {
            if (selection instanceof Field) {
                overlappingFields_collectFieldsForField(fieldMap, parentType, (Field) selection);
            } else if (selection instanceof InlineFragment) {
                overlappingFields_collectFieldsForInlineFragment(fieldMap, visitedFragments, parentType, (InlineFragment) selection);
            } else if (selection instanceof FragmentSpread) {
                overlappingFields_collectFieldsForFragmentSpread(fieldMap, visitedFragments, (FragmentSpread) selection);
            }
        }
    }

    private void overlappingFields_collectFieldsForFragmentSpread(Map<String, Set<FieldAndType>> fieldMap, Set<String> visitedFragments, FragmentSpread fragmentSpread) {
        FragmentDefinition fragment = validationContext.getFragment(fragmentSpread.getName());
        if (fragment == null) {
            return;
        }
        if (visitedFragments.contains(fragment.getName())) {
            return;
        }
        visitedFragments.add(fragment.getName());
        GraphQLType graphQLType = TypeFromAST.getTypeFromAST(validationContext.getSchema(), fragment.getTypeCondition());
        overlappingFields_collectFields(fieldMap, fragment.getSelectionSet(), graphQLType, visitedFragments);
    }

    private void overlappingFields_collectFieldsForInlineFragment(Map<String, Set<FieldAndType>> fieldMap, Set<String> visitedFragments, @Nullable GraphQLType parentType, InlineFragment inlineFragment) {
        GraphQLType graphQLType;
        if (inlineFragment.getTypeCondition() == null) {
            graphQLType = parentType;
        } else {
            graphQLType = TypeFromAST.getTypeFromAST(validationContext.getSchema(), inlineFragment.getTypeCondition());
        }
        overlappingFields_collectFields(fieldMap, inlineFragment.getSelectionSet(), graphQLType, visitedFragments);
    }

    private void overlappingFields_collectFieldsForField(Map<String, Set<FieldAndType>> fieldMap, @Nullable GraphQLType parentType, Field field) {
        String responseName = field.getResultKey();
        GraphQLOutputType fieldType = null;
        GraphQLUnmodifiedType unwrappedParent = parentType != null ? unwrapAll(parentType) : null;
        if (unwrappedParent instanceof GraphQLFieldsContainer) {
            GraphQLFieldsContainer fieldsContainer = (GraphQLFieldsContainer) unwrappedParent;
            GraphQLFieldDefinition fieldDefinition = validationContext.getSchema().getCodeRegistry().getFieldVisibility().getFieldDefinition(fieldsContainer, field.getName());
            fieldType = fieldDefinition != null ? fieldDefinition.getType() : null;
        }
        fieldMap.computeIfAbsent(responseName, k -> new LinkedHashSet<>()).add(new FieldAndType(field, fieldType, unwrappedParent));
    }

    private List<Conflict> findConflicts(Map<String, Set<FieldAndType>> fieldMap) {
        List<Conflict> result = new ArrayList<>();
        sameResponseShapeByName(fieldMap, emptyList(), result);
        sameForCommonParentsByName(fieldMap, emptyList(), result);
        return result;
    }

    private void sameResponseShapeByName(Map<String, Set<FieldAndType>> fieldMap, ImmutableList<String> currentPath, List<Conflict> conflictsResult) {
        for (Map.Entry<String, Set<FieldAndType>> entry : fieldMap.entrySet()) {
            if (sameResponseShapeChecked.contains(entry.getValue())) {
                continue;
            }
            ImmutableList<String> newPath = addToList(currentPath, entry.getKey());
            sameResponseShapeChecked.add(entry.getValue());
            Conflict conflict = requireSameOutputTypeShape(newPath, entry.getValue());
            if (conflict != null) {
                conflictsResult.add(conflict);
                continue;
            }
            Map<String, Set<FieldAndType>> subSelections = mergeSubSelections(entry.getValue());
            sameResponseShapeByName(subSelections, newPath, conflictsResult);
        }
    }

    private Map<String, Set<FieldAndType>> mergeSubSelections(Set<FieldAndType> sameNameFields) {
        Map<String, Set<FieldAndType>> fieldMap = new LinkedHashMap<>();
        for (FieldAndType fieldAndType : sameNameFields) {
            if (fieldAndType.field.getSelectionSet() != null) {
                Set<String> visitedFragments = new LinkedHashSet<>();
                overlappingFields_collectFields(fieldMap, fieldAndType.field.getSelectionSet(), fieldAndType.graphQLType, visitedFragments);
            }
        }
        return fieldMap;
    }

    private void sameForCommonParentsByName(Map<String, Set<FieldAndType>> fieldMap, ImmutableList<String> currentPath, List<Conflict> conflictsResult) {
        for (Map.Entry<String, Set<FieldAndType>> entry : fieldMap.entrySet()) {
            List<Set<FieldAndType>> groups = groupByCommonParents(entry.getValue());
            ImmutableList<String> newPath = addToList(currentPath, entry.getKey());
            for (Set<FieldAndType> group : groups) {
                if (sameForCommonParentsChecked.contains(group)) {
                    continue;
                }
                sameForCommonParentsChecked.add(group);
                Conflict conflict = requireSameNameAndArguments(newPath, group);
                if (conflict != null) {
                    conflictsResult.add(conflict);
                    continue;
                }
                Map<String, Set<FieldAndType>> subSelections = mergeSubSelections(group);
                sameForCommonParentsByName(subSelections, newPath, conflictsResult);
            }
        }
    }

    private List<Set<FieldAndType>> groupByCommonParents(Set<FieldAndType> fields) {
        // Single-pass: partition into abstract types and concrete groups simultaneously
        List<FieldAndType> abstractTypes = null;
        Map<GraphQLType, Set<FieldAndType>> concreteGroups = null;

        for (FieldAndType fieldAndType : fields) {
            if (isInterfaceOrUnion(fieldAndType.parentType)) {
                if (abstractTypes == null) {
                    abstractTypes = new ArrayList<>();
                }
                abstractTypes.add(fieldAndType);
            } else if (fieldAndType.parentType instanceof GraphQLObjectType) {
                if (concreteGroups == null) {
                    concreteGroups = new LinkedHashMap<>();
                }
                concreteGroups.computeIfAbsent(fieldAndType.parentType, k -> new LinkedHashSet<>()).add(fieldAndType);
            }
        }

        if (concreteGroups == null || concreteGroups.isEmpty()) {
            // No concrete types ��� return all abstract types as a single group
            if (abstractTypes == null) {
                return Collections.singletonList(fields);
            }
            return Collections.singletonList(new LinkedHashSet<>(abstractTypes));
        }

        List<Set<FieldAndType>> result = new ArrayList<>(concreteGroups.size());
        for (Set<FieldAndType> concreteGroup : concreteGroups.values()) {
            if (abstractTypes != null) {
                concreteGroup.addAll(abstractTypes);
            }
            result.add(concreteGroup);
        }
        return result;
    }

    private boolean isInterfaceOrUnion(@Nullable GraphQLType type) {
        return type instanceof GraphQLInterfaceType || type instanceof GraphQLUnionType;
    }

    private @Nullable Conflict requireSameNameAndArguments(ImmutableList<String> path, Set<FieldAndType> fieldAndTypes) {
        if (fieldAndTypes.size() <= 1) {
            return null;
        }
        String name = null;
        List<Argument> arguments = null;
        List<Field> fields = new ArrayList<>();
        for (FieldAndType fieldAndType : fieldAndTypes) {
            Field field = fieldAndType.field;
            fields.add(field);
            if (name == null) {
                name = field.getName();
                arguments = field.getArguments();
                continue;
            }
            if (!field.getName().equals(name)) {
                String reason = i18n(FieldsConflict, "OverlappingFieldsCanBeMerged.differentFields", pathToString(path), name, field.getName());
                return new Conflict(reason, fields);
            }
            if (!sameArguments(field.getArguments(), arguments)) {
                String reason = i18n(FieldsConflict, "OverlappingFieldsCanBeMerged.differentArgs", pathToString(path));
                return new Conflict(reason, fields);
            }
        }
        return null;
    }

    private String pathToString(ImmutableList<String> path) {
        return String.join("/", path);
    }

    private boolean sameArguments(List<Argument> arguments1, @Nullable List<Argument> arguments2) {
        if (arguments2 == null || arguments1.size() != arguments2.size()) {
            return false;
        }
        for (Argument argument : arguments1) {
            Argument matchedArgument = findArgumentByName(argument.getName(), arguments2);
            if (matchedArgument == null) {
                return false;
            }
            if (!AstComparator.sameValue(argument.getValue(), matchedArgument.getValue())) {
                return false;
            }
        }
        return true;
    }

    private @Nullable Argument findArgumentByName(String name, List<Argument> arguments) {
        for (Argument argument : arguments) {
            if (argument.getName().equals(name)) {
                return argument;
            }
        }
        return null;
    }

    private @Nullable Conflict requireSameOutputTypeShape(ImmutableList<String> path, Set<FieldAndType> fieldAndTypes) {
        if (fieldAndTypes.size() <= 1) {
            return null;
        }
        List<Field> fields = new ArrayList<>();
        GraphQLType typeAOriginal = null;
        for (FieldAndType fieldAndType : fieldAndTypes) {
            fields.add(fieldAndType.field);
            if (typeAOriginal == null) {
                typeAOriginal = fieldAndType.graphQLType;
                continue;
            }
            GraphQLType typeA = typeAOriginal;
            GraphQLType typeB = fieldAndType.graphQLType;
            if (typeB == null) {
                return mkNotSameTypeError(path, fields, typeA, typeB);
            }
            while (true) {
                if (isNonNull(typeA) || isNonNull(typeB)) {
                    if (isNullable(typeA) || isNullable(typeB)) {
                        String reason = i18n(FieldsConflict, "OverlappingFieldsCanBeMerged.differentNullability", pathToString(path));
                        return new Conflict(reason, fields);
                    }
                }
                if (isList(typeA) || isList(typeB)) {
                    if (!isList(typeA) || !isList(typeB)) {
                        String reason = i18n(FieldsConflict, "OverlappingFieldsCanBeMerged.differentLists", pathToString(path));
                        return new Conflict(reason, fields);
                    }
                }
                if (isNotWrapped(typeA) && isNotWrapped(typeB)) {
                    break;
                }
                typeA = unwrapOne(typeA);
                typeB = unwrapOne(typeB);
            }
            if (isScalar(typeA) || isScalar(typeB)) {
                if (notSameType(typeA, typeB)) {
                    return mkNotSameTypeError(path, fields, typeA, typeB);
                }
            }
            if (isEnum(typeA) || isEnum(typeB)) {
                if (notSameType(typeA, typeB)) {
                    return mkNotSameTypeError(path, fields, typeA, typeB);
                }
            }
        }
        return null;
    }

    private Conflict mkNotSameTypeError(ImmutableList<String> path, List<Field> fields, @Nullable GraphQLType typeA, @Nullable GraphQLType typeB) {
        String name1 = typeA != null ? simplePrint(typeA) : "null";
        String name2 = typeB != null ? simplePrint(typeB) : "null";
        String reason = i18n(FieldsConflict, "OverlappingFieldsCanBeMerged.differentReturnTypes", pathToString(path), name1, name2);
        return new Conflict(reason, fields);
    }

    private boolean notSameType(@Nullable GraphQLType type1, @Nullable GraphQLType type2) {
        if (type1 == null || type2 == null) {
            return false;
        }
        return !type1.equals(type2);
    }

    private static class FieldAndType {
        final Field field;
        final @Nullable GraphQLType graphQLType;
        final @Nullable GraphQLType parentType;

        public FieldAndType(Field field, @Nullable GraphQLType graphQLType, @Nullable GraphQLType parentType) {
            this.field = field;
            this.graphQLType = graphQLType;
            this.parentType = parentType;
        }

        @Override
        public String toString() {
            return "FieldAndType{" +
                    "field=" + field +
                    ", graphQLType=" + graphQLType +
                    ", parentType=" + parentType +
                    '}';
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            FieldAndType that = (FieldAndType) o;
            return Objects.equals(field, that.field);
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(field);
        }
    }

    private static class Conflict {
        final String reason;
        final Set<Field> fields = new LinkedHashSet<>();

        public Conflict(String reason, List<Field> fields) {
            this.reason = reason;
            this.fields.addAll(fields);
        }
    }

    // --- PossibleFragmentSpreads ---
    private void validatePossibleFragmentSpreads_inline(InlineFragment inlineFragment) {
        GraphQLOutputType fragType = validationContext.getOutputType();
        GraphQLCompositeType parentType = validationContext.getParentType();
        if (fragType == null || parentType == null) {
            return;
        }

        if (isValidTargetCompositeType(fragType) && isValidTargetCompositeType(parentType) && typesDoNotOverlap(fragType, parentType)) {
            String message = i18n(InvalidFragmentType, "PossibleFragmentSpreads.inlineIncompatibleTypes", parentType.getName(), simplePrint(fragType));
            addError(InvalidFragmentType, inlineFragment.getSourceLocation(), message);
        }
    }

    private void validatePossibleFragmentSpreads_spread(FragmentSpread fragmentSpread) {
        FragmentDefinition fragment = validationContext.getFragment(fragmentSpread.getName());
        if (fragment == null) {
            return;
        }
        GraphQLType typeCondition = TypeFromAST.getTypeFromAST(validationContext.getSchema(), fragment.getTypeCondition());
        GraphQLCompositeType parentType = validationContext.getParentType();
        if (typeCondition == null || parentType == null) {
            return;
        }

        if (isValidTargetCompositeType(typeCondition) && isValidTargetCompositeType(parentType) && typesDoNotOverlap(typeCondition, parentType)) {
            String message = i18n(InvalidFragmentType, "PossibleFragmentSpreads.fragmentIncompatibleTypes", fragmentSpread.getName(), parentType.getName(), simplePrint(typeCondition));
            addError(InvalidFragmentType, fragmentSpread.getSourceLocation(), message);
        }
    }

    private boolean typesDoNotOverlap(GraphQLType type, GraphQLCompositeType parent) {
        if (type == parent) {
            return false;
        }
        List<? extends GraphQLType> possibleParentTypes = getPossibleType(parent);
        List<? extends GraphQLType> possibleConditionTypes = getPossibleType(type);
        return Collections.disjoint(possibleParentTypes, possibleConditionTypes);
    }

    private List<? extends GraphQLType> getPossibleType(GraphQLType type) {
        if (type instanceof GraphQLObjectType) {
            return Collections.singletonList(type);
        } else if (type instanceof GraphQLInterfaceType) {
            List<GraphQLObjectType> implementations = validationContext.getSchema().getImplementations((GraphQLInterfaceType) type);
            return implementations != null ? implementations : Collections.emptyList();
        } else if (type instanceof GraphQLUnionType) {
            return ((GraphQLUnionType) type).getTypes();
        } else {
            Assert.assertShouldNeverHappen();
        }
        return Collections.emptyList();
    }

    private boolean isValidTargetCompositeType(GraphQLType type) {
        return type instanceof GraphQLCompositeType;
    }

    // --- ProvidedNonNullArguments ---
    private void validateProvidedNonNullArguments_field(Field field) {
        GraphQLFieldDefinition fieldDef = validationContext.getFieldDef();
        if (fieldDef == null) {
            return;
        }
        List<Argument> providedArguments = field.getArguments();

        for (GraphQLArgument graphQLArgument : fieldDef.getArguments()) {
            Argument argument = findArgumentByName(providedArguments, graphQLArgument.getName());
            boolean nonNullType = isNonNull(graphQLArgument.getType());
            boolean noDefaultValue = graphQLArgument.getArgumentDefaultValue().isNotSet();
            if (argument == null && nonNullType && noDefaultValue) {
                String message = i18n(MissingFieldArgument, "ProvidedNonNullArguments.missingFieldArg", graphQLArgument.getName());
                addError(MissingFieldArgument, field.getSourceLocation(), message);
            }
            if (argument != null) {
                Value value = argument.getValue();
                if (value instanceof NullValue && nonNullType && noDefaultValue) {
                    String message = i18n(NullValueForNonNullArgument, "ProvidedNonNullArguments.nullValue", graphQLArgument.getName());
                    addError(NullValueForNonNullArgument, field.getSourceLocation(), message);
                }
            }
        }
    }

    private void validateProvidedNonNullArguments_directive(Directive directive) {
        GraphQLDirective graphQLDirective = validationContext.getDirective();
        if (graphQLDirective == null) {
            return;
        }
        List<Argument> providedArguments = directive.getArguments();

        for (GraphQLArgument graphQLArgument : graphQLDirective.getArguments()) {
            Argument argument = findArgumentByName(providedArguments, graphQLArgument.getName());
            boolean nonNullType = isNonNull(graphQLArgument.getType());
            boolean noDefaultValue = graphQLArgument.getArgumentDefaultValue().isNotSet();
            if (argument == null && nonNullType && noDefaultValue) {
                String message = i18n(MissingDirectiveArgument, "ProvidedNonNullArguments.missingDirectiveArg", graphQLArgument.getName());
                addError(MissingDirectiveArgument, directive.getSourceLocation(), message);
            }
        }
    }

    private static @Nullable Argument findArgumentByName(List<Argument> arguments, String name) {
        for (Argument argument : arguments) {
            if (argument.getName().equals(name)) {
                return argument;
            }
        }
        return null;
    }

    // --- ScalarLeaves ---
    private void validateScalarLeaves(Field field) {
        GraphQLOutputType type = validationContext.getOutputType();
        if (type == null) {
            return;
        }
        if (isLeaf(type)) {
            if (field.getSelectionSet() != null) {
                String message = i18n(SubselectionNotAllowed, "ScalarLeaves.subselectionOnLeaf", simplePrint(type), field.getName());
                addError(SubselectionNotAllowed, field.getSourceLocation(), message);
            }
        } else {
            if (field.getSelectionSet() == null) {
                String message = i18n(SubselectionRequired, "ScalarLeaves.subselectionRequired", simplePrint(type), field.getName());
                addError(SubselectionRequired, field.getSourceLocation(), message);
            }
        }
    }

    // --- VariableDefaultValuesOfCorrectType ---
    private void validateVariableDefaultValuesOfCorrectType(VariableDefinition variableDefinition) {
        GraphQLInputType inputType = validationContext.getInputType();
        if (inputType == null) {
            return;
        }
        if (variableDefinition.getDefaultValue() != null
                && !validationUtil.isValidLiteralValue(variableDefinition.getDefaultValue(), inputType,
                validationContext.getSchema(), validationContext.getGraphQLContext(), validationContext.getI18n().getLocale())) {
            String message = i18n(BadValueForDefaultArg, "VariableDefaultValuesOfCorrectType.badDefault", variableDefinition.getDefaultValue(), simplePrint(inputType));
            addError(BadValueForDefaultArg, variableDefinition.getSourceLocation(), message);
        }
    }

    // --- VariablesAreInputTypes ---
    private void validateVariablesAreInputTypes(VariableDefinition variableDefinition) {
        TypeName unmodifiedAstType = validationUtil.getUnmodifiedType(variableDefinition.getType());
        GraphQLType type = validationContext.getSchema().getType(unmodifiedAstType.getName());
        if (type == null) {
            return;
        }
        if (!isInput(type)) {
            String message = i18n(NonInputTypeOnVariable, "VariablesAreInputTypes.wrongType", variableDefinition.getName(), unmodifiedAstType.getName());
            addError(NonInputTypeOnVariable, variableDefinition.getSourceLocation(), message);
        }
    }

    // --- VariableTypesMatch ---
    private void validateVariableTypesMatch(VariableReference variableReference) {
        if (variableDefinitionMap == null) {
            return;
        }
        VariableDefinition variableDefinition = variableDefinitionMap.get(variableReference.getName());
        if (variableDefinition == null) {
            return;
        }
        GraphQLType variableType = TypeFromAST.getTypeFromAST(validationContext.getSchema(), variableDefinition.getType());
        if (variableType == null) {
            return;
        }
        GraphQLInputType locationType = validationContext.getInputType();
        Optional<InputValueWithState> locationDefault = Optional.ofNullable(validationContext.getDefaultValue());
        if (locationType == null) {
            return;
        }
        Value<?> locationDefaultValue = null;
        if (locationDefault.isPresent() && locationDefault.get().isLiteral()) {
            locationDefaultValue = (Value<?>) locationDefault.get().getValue();
        } else if (locationDefault.isPresent() && locationDefault.get().isSet()) {
            locationDefaultValue = ValuesResolver.valueToLiteral(locationDefault.get(), locationType,
                    validationContext.getGraphQLContext(), validationContext.getI18n().getLocale());
        }
        boolean variableDefMatches = variablesTypesMatcher.doesVariableTypesMatch(variableType, variableDefinition.getDefaultValue(), locationType, locationDefaultValue);
        if (!variableDefMatches) {
            GraphQLType effectiveType = variablesTypesMatcher.effectiveType(variableType, variableDefinition.getDefaultValue());
            String message = i18n(VariableTypeMismatch, "VariableTypesMatchRule.unexpectedType",
                    variableDefinition.getName(),
                    GraphQLTypeUtil.simplePrint(effectiveType),
                    GraphQLTypeUtil.simplePrint(locationType));
            addError(VariableTypeMismatch, variableReference.getSourceLocation(), message);
        }
    }

    // --- LoneAnonymousOperation ---
    private void validateLoneAnonymousOperation(OperationDefinition operationDefinition) {
        String name = operationDefinition.getName();
        if (name == null) {
            hasAnonymousOp = true;
            if (loneAnon_count > 0) {
                String message = i18n(LoneAnonymousOperationViolation, "LoneAnonymousOperation.withOthers");
                addError(LoneAnonymousOperationViolation, operationDefinition.getSourceLocation(), message);
            }
        } else {
            if (hasAnonymousOp) {
                String message = i18n(LoneAnonymousOperationViolation, "LoneAnonymousOperation.namedOperation", name);
                addError(LoneAnonymousOperationViolation, operationDefinition.getSourceLocation(), message);
            }
        }
        loneAnon_count++;
    }

    // --- UniqueOperationNames ---
    private void validateUniqueOperationNames(OperationDefinition operationDefinition) {
        String name = operationDefinition.getName();
        if (name == null) {
            return;
        }
        if (operationNames.contains(name)) {
            String message = i18n(DuplicateOperationName, "UniqueOperationNames.oneOperation", operationDefinition.getName());
            addError(DuplicateOperationName, operationDefinition.getSourceLocation(), message);
        } else {
            operationNames.add(name);
        }
    }

    // --- UniqueFragmentNames ---
    private void validateUniqueFragmentNames(FragmentDefinition fragmentDefinition) {
        String name = fragmentDefinition.getName();
        if (name == null) {
            return;
        }
        if (fragmentNames.contains(name)) {
            String message = i18n(DuplicateFragmentName, "UniqueFragmentNames.oneFragment", name);
            addError(DuplicateFragmentName, fragmentDefinition.getSourceLocation(), message);
        } else {
            fragmentNames.add(name);
        }
    }

    // --- UniqueDirectiveNamesPerLocation ---
    private void validateUniqueDirectiveNamesPerLocation(Node<?> directivesContainer, List<Directive> directives) {
        Set<String> directiveNames = new LinkedHashSet<>();
        for (Directive directive : directives) {
            String name = directive.getName();
            GraphQLDirective graphQLDirective = validationContext.getSchema().getDirective(name);
            boolean nonRepeatable = graphQLDirective != null && graphQLDirective.isNonRepeatable();
            if (directiveNames.contains(name) && nonRepeatable) {
                String message = i18n(DuplicateDirectiveName, "UniqueDirectiveNamesPerLocation.uniqueDirectives", name, directivesContainer.getClass().getSimpleName());
                addError(DuplicateDirectiveName, directive.getSourceLocation(), message);
            } else {
                directiveNames.add(name);
            }
        }
    }

    // --- UniqueArgumentNames ---
    private void validateUniqueArgumentNames_field(Field field) {
        validateUniqueArgumentNames(field.getArguments(), field.getSourceLocation());
    }

    private void validateUniqueArgumentNames_directive(Directive directive) {
        validateUniqueArgumentNames(directive.getArguments(), directive.getSourceLocation());
    }

    private void validateUniqueArgumentNames(List<Argument> argumentList, @Nullable SourceLocation sourceLocation) {
        if (argumentList.size() <= 1) {
            return;
        }
        Set<String> arguments = Sets.newHashSetWithExpectedSize(argumentList.size());
        for (Argument argument : argumentList) {
            if (arguments.contains(argument.getName())) {
                String message = i18n(DuplicateArgumentNames, "UniqueArgumentNames.uniqueArgument", argument.getName());
                addError(DuplicateArgumentNames, sourceLocation, message);
            } else {
                arguments.add(argument.getName());
            }
        }
    }

    // --- UniqueVariableNames ---
    private void validateUniqueVariableNames(OperationDefinition operationDefinition) {
        List<VariableDefinition> variableDefinitions = operationDefinition.getVariableDefinitions();
        if (variableDefinitions == null || variableDefinitions.size() <= 1) {
            return;
        }
        Set<String> variableNameList = Sets.newLinkedHashSetWithExpectedSize(variableDefinitions.size());
        for (VariableDefinition variableDefinition : variableDefinitions) {
            if (variableNameList.contains(variableDefinition.getName())) {
                String message = i18n(DuplicateVariableName, "UniqueVariableNames.oneVariable", variableDefinition.getName());
                addError(DuplicateVariableName, variableDefinition.getSourceLocation(), message);
            } else {
                variableNameList.add(variableDefinition.getName());
            }
        }
    }

    // --- SubscriptionUniqueRootField ---
    private void validateSubscriptionUniqueRootField(OperationDefinition operationDef) {
        if (operationDef.getOperation() == SUBSCRIPTION) {
            GraphQLObjectType subscriptionType = validationContext.getSchema().getSubscriptionType();
            FieldCollectorParameters collectorParameters = FieldCollectorParameters.newParameters()
                    .schema(validationContext.getSchema())
                    .fragments(NodeUtil.getFragmentsByName(validationContext.getDocument()))
                    .variables(CoercedVariables.emptyVariables().toMap())
                    .objectType(subscriptionType)
                    .graphQLContext(validationContext.getGraphQLContext())
                    .build();
            MergedSelectionSet fields = fieldCollector.collectFields(collectorParameters, operationDef.getSelectionSet());
            if (fields.size() > 1) {
                String message = i18n(SubscriptionMultipleRootFields, "SubscriptionUniqueRootField.multipleRootFields", operationDef.getName());
                addError(SubscriptionMultipleRootFields, operationDef.getSourceLocation(), message);
            } else {
                MergedField mergedField = fields.getSubFieldsList().get(0);
                if (isIntrospectionField(mergedField)) {
                    String message = i18n(SubscriptionIntrospectionRootField, "SubscriptionIntrospectionRootField.introspectionRootField", operationDef.getName(), mergedField.getName());
                    addError(SubscriptionIntrospectionRootField, mergedField.getSingleField().getSourceLocation(), message);
                }
            }
        }
    }

    private boolean isIntrospectionField(MergedField field) {
        return field.getName().startsWith("__");
    }

    // --- UniqueObjectFieldName ---
    private void validateUniqueObjectFieldName(ObjectValue objectValue) {
        Set<String> fieldNames = Sets.newHashSetWithExpectedSize(objectValue.getObjectFields().size());
        for (ObjectField field : objectValue.getObjectFields()) {
            String fieldName = field.getName();
            if (fieldNames.contains(fieldName)) {
                String message = i18n(UniqueObjectFieldName, "UniqueObjectFieldName.duplicateFieldName", fieldName);
                addError(UniqueObjectFieldName, objectValue.getSourceLocation(), message);
            } else {
                fieldNames.add(fieldName);
            }
        }
    }

    // --- DeferDirectiveOnRootLevel ---
    private void validateDeferDirectiveOnRootLevel(Directive directive) {
        if (!isExperimentalApiKeyEnabled(ExperimentalApi.ENABLE_INCREMENTAL_SUPPORT)) {
            return;
        }
        if (!Directives.DeferDirective.getName().equals(directive.getName())) {
            return;
        }
        GraphQLObjectType mutationType = validationContext.getSchema().getMutationType();
        GraphQLObjectType subscriptionType = validationContext.getSchema().getSubscriptionType();
        GraphQLCompositeType parentType = validationContext.getParentType();
        if (mutationType != null && parentType != null && parentType.getName().equals(mutationType.getName())) {
            String message = i18n(MisplacedDirective, "DeferDirective.notAllowedOperationRootLevelMutation", parentType.getName());
            addError(MisplacedDirective, directive.getSourceLocation(), message);
        } else if (subscriptionType != null && parentType != null && parentType.getName().equals(subscriptionType.getName())) {
            String message = i18n(MisplacedDirective, "DeferDirective.notAllowedOperationRootLevelSubscription", parentType.getName());
            addError(MisplacedDirective, directive.getSourceLocation(), message);
        }
    }

    // --- DeferDirectiveOnValidOperation ---
    private void validateDeferDirectiveOnValidOperation(Directive directive, List<Node> ancestors) {
        if (!isExperimentalApiKeyEnabled(ExperimentalApi.ENABLE_INCREMENTAL_SUPPORT)) {
            return;
        }
        if (!Directives.DeferDirective.getName().equals(directive.getName())) {
            return;
        }
        Optional<OperationDefinition> operationDefinition = getOperationDefinition(ancestors);
        if (operationDefinition.isPresent() &&
                SUBSCRIPTION.equals(operationDefinition.get().getOperation()) &&
                !ifArgumentMightBeFalse(directive)) {
            String message = i18n(MisplacedDirective, "IncrementalDirective.notAllowedSubscriptionOperation", directive.getName());
            addError(MisplacedDirective, directive.getSourceLocation(), message);
        }
    }

    private Optional<OperationDefinition> getOperationDefinition(List<Node> ancestors) {
        return ancestors.stream()
                .filter(doc -> doc instanceof OperationDefinition)
                .map(def -> (OperationDefinition) def)
                .findFirst();
    }

    private boolean ifArgumentMightBeFalse(Directive directive) {
        Argument ifArgument = directive.getArgumentsByName().get("if");
        if (ifArgument == null) {
            return false;
        }
        if (ifArgument.getValue() instanceof BooleanValue) {
            return !((BooleanValue) ifArgument.getValue()).isValue();
        }
        return ifArgument.getValue() instanceof VariableReference;
    }

    // --- DeferDirectiveLabel ---
    private void validateDeferDirectiveLabel(Directive directive) {
        if (!isExperimentalApiKeyEnabled(ExperimentalApi.ENABLE_INCREMENTAL_SUPPORT) ||
                !Directives.DeferDirective.getName().equals(directive.getName()) ||
                directive.getArguments().size() == 0) {
            return;
        }
        Argument labelArgument = directive.getArgument("label");
        if (labelArgument == null || labelArgument.getValue() instanceof NullValue) {
            return;
        }
        Value labelArgumentValue = labelArgument.getValue();
        if (!(labelArgumentValue instanceof StringValue)) {
            String message = i18n(WrongType, "DeferDirective.labelMustBeStaticString");
            addError(WrongType, directive.getSourceLocation(), message);
        } else {
            String labelValue = ((StringValue) labelArgumentValue).getValue();
            if (labelValue != null && checkedDeferLabels.contains(labelValue)) {
                String message = i18n(DuplicateIncrementalLabel, "IncrementalDirective.uniqueArgument", labelArgument.getName(), directive.getName());
                addError(DuplicateIncrementalLabel, directive.getSourceLocation(), message);
            } else if (labelValue != null) {
                checkedDeferLabels.add(labelValue);
            }
        }
    }

    // --- KnownOperationTypes ---
    private void validateKnownOperationTypes(OperationDefinition operationDefinition) {
        OperationDefinition.Operation documentOperation = operationDefinition.getOperation();
        if (documentOperation == OperationDefinition.Operation.MUTATION
                && validationContext.getSchema().getMutationType() == null) {
            String message = i18n(UnknownOperation, "KnownOperationTypes.noOperation", formatOperation(documentOperation));
            addError(UnknownOperation, operationDefinition.getSourceLocation(), message);
        } else if (documentOperation == OperationDefinition.Operation.SUBSCRIPTION
                && validationContext.getSchema().getSubscriptionType() == null) {
            String message = i18n(UnknownOperation, "KnownOperationTypes.noOperation", formatOperation(documentOperation));
            addError(UnknownOperation, operationDefinition.getSourceLocation(), message);
        }
        // Note: No check for QUERY - a GraphQL schema always has a query type
    }

    private String formatOperation(OperationDefinition.Operation operation) {
        return StringKit.capitalize(operation.name().toLowerCase());
    }

    @Override
    public String toString() {
        return "OperationValidator{" + validationContext + "}";
    }
}