diff --git a/docs/graphql-linting.md b/docs/graphql-linting.md index 05a6147..25186d7 100644 --- a/docs/graphql-linting.md +++ b/docs/graphql-linting.md @@ -83,6 +83,17 @@ The custom GraphQL linter includes the following built-in rules: | `no-duplicate-fields` | error | Prevent duplicate field definitions | | `require-description` | warn | Require descriptions for types/fields | | `require-deprecation-reason` | warn | Require reason for deprecated fields | +| `field-naming-convention` | warn | Enforce camelCase field naming | +| `root-fields-nullable` | warn | Suggest nullable root field types | + +### Pagination Rules + +| Rule | Severity | Description | +| ------------------------------ | -------- | ---------------------------------------------- | +| `connection-structure` | error | Ensure Connection types have edges/pageInfo | +| `edge-structure` | error | Ensure Edge types have node/cursor fields | +| `connection-arguments` | warn | Suggest pagination arguments for connections | +| `pagination-argument-types` | error | Enforce correct types for pagination arguments | ### Rule Details @@ -90,6 +101,12 @@ The custom GraphQL linter includes the following built-in rules: - **no-duplicate-fields**: Prevents duplicate field definitions within the same type - **require-description**: Suggests adding descriptions to types and fields for better documentation - **require-deprecation-reason**: Ensures deprecated fields include a reason for deprecation +- **field-naming-convention**: Enforces camelCase naming for field names (ignores special fields like `__typename`) +- **root-fields-nullable**: Suggests making root type fields nullable for better error handling +- **connection-structure**: Ensures Connection types follow the Relay pagination pattern with `edges` and `pageInfo` fields +- **edge-structure**: Ensures Edge types have the required `node` and `cursor` fields +- **connection-arguments**: Suggests adding `first` and `after` arguments to fields returning Connection types +- **pagination-argument-types**: Enforces correct types for pagination arguments (`first: Int!`, `after: String`) ## Usage diff --git a/package.json b/package.json index 7a7a75c..e6b4922 100644 --- a/package.json +++ b/package.json @@ -229,7 +229,7 @@ "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run check-types && npm run lint && node esbuild.js --production", - "compile-tests": "tsc -p . --outDir out", + "compile-tests": "tsc -p . --outDir out && mkdir -p out/test/fixtures && cp -r src/test/fixtures/* out/test/fixtures/", "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", "check-types": "tsc --noEmit", diff --git a/src/services/graphqlLinter.ts b/src/services/graphqlLinter.ts index 41aca77..2ad9008 100644 --- a/src/services/graphqlLinter.ts +++ b/src/services/graphqlLinter.ts @@ -3,18 +3,25 @@ * Assisted by CursorAI */ -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { parse, visit, DocumentNode, ObjectTypeDefinitionNode, FieldDefinitionNode, OperationDefinitionNode } from 'graphql'; -import { logger } from './logger'; -import { StepZenError } from '../errors'; +import * as vscode from "vscode"; +import * as fs from "fs"; +import { + parse, + visit, + DocumentNode, + ObjectTypeDefinitionNode, + FieldDefinitionNode, + OperationDefinitionNode, +} from "graphql"; +import { logger } from "./logger"; +import { StepZenError } from "../errors"; /** * Custom GraphQL linting rule interface */ interface GraphQLLintRule { name: string; - severity: 'error' | 'warn' | 'info'; + severity: "error" | "warn" | "info"; check: (ast: DocumentNode) => GraphQLLintIssue[]; } @@ -28,7 +35,7 @@ interface GraphQLLintIssue { endLine?: number; endColumn?: number; rule: string; - severity: 'error' | 'warn' | 'info'; + severity: "error" | "warn" | "info"; } /** @@ -41,7 +48,9 @@ export class GraphQLLinterService { private isInitialized = false; constructor() { - this.diagnosticCollection = vscode.languages.createDiagnosticCollection('stepzen-graphql-lint'); + this.diagnosticCollection = vscode.languages.createDiagnosticCollection( + "stepzen-graphql-lint", + ); this.initializeRules(); } @@ -50,50 +59,55 @@ export class GraphQLLinterService { */ private initializeRules(): void { // Get enabled rules from configuration - const config = vscode.workspace.getConfiguration('stepzen'); - const enabledRules = config.get('graphqlLintRules', { - 'no-anonymous-operations': true, - 'no-duplicate-fields': true, - 'require-description': true, - 'require-deprecation-reason': true, - 'field-naming-convention': true, - 'root-fields-nullable': true + const config = vscode.workspace.getConfiguration("stepzen"); + const enabledRules = config.get("graphqlLintRules", { + "no-anonymous-operations": true, + "no-duplicate-fields": true, + "require-description": true, + "require-deprecation-reason": true, + "field-naming-convention": true, + "root-fields-nullable": true, + "connection-structure": true, + "edge-structure": true, + "connection-arguments": true, + "pagination-argument-types": true, }); const allRules: GraphQLLintRule[] = []; // Rule: No anonymous operations - if (enabledRules['no-anonymous-operations']) { + if (enabledRules["no-anonymous-operations"]) { allRules.push({ - name: 'no-anonymous-operations', - severity: 'error' as const, + name: "no-anonymous-operations", + severity: "error" as const, check: (ast: DocumentNode): GraphQLLintIssue[] => { const issues: GraphQLLintIssue[] = []; visit(ast, { OperationDefinition(node: OperationDefinitionNode) { if (!node.name && node.loc) { issues.push({ - message: 'Anonymous operations are not allowed. Please provide a name for this operation.', + message: + "Anonymous operations are not allowed. Please provide a name for this operation.", line: node.loc.startToken.line, column: node.loc.startToken.column, endLine: node.loc.endToken.line, endColumn: node.loc.endToken.column, - rule: 'no-anonymous-operations', - severity: 'error' + rule: "no-anonymous-operations", + severity: "error", }); } - } + }, }); return issues; - } + }, }); } // Rule: No duplicate fields - if (enabledRules['no-duplicate-fields']) { + if (enabledRules["no-duplicate-fields"]) { allRules.push({ - name: 'no-duplicate-fields', - severity: 'error' as const, + name: "no-duplicate-fields", + severity: "error" as const, check: (ast: DocumentNode): GraphQLLintIssue[] => { const issues: GraphQLLintIssue[] = []; visit(ast, { @@ -101,7 +115,7 @@ export class GraphQLLinterService { const fieldNames = new Set(); const duplicateFields = new Set(); - node.fields?.forEach(field => { + node.fields?.forEach((field) => { const fieldName = field.name.value; if (fieldNames.has(fieldName)) { duplicateFields.add(fieldName); @@ -110,8 +124,8 @@ export class GraphQLLinterService { } }); - duplicateFields.forEach(fieldName => { - node.fields?.forEach(field => { + duplicateFields.forEach((fieldName) => { + node.fields?.forEach((field) => { if (field.name.value === fieldName && field.loc) { issues.push({ message: `Duplicate field '${fieldName}' found in type '${node.name.value}'`, @@ -119,24 +133,24 @@ export class GraphQLLinterService { column: field.loc.startToken.column, endLine: field.loc.endToken.line, endColumn: field.loc.endToken.column, - rule: 'no-duplicate-fields', - severity: 'error' + rule: "no-duplicate-fields", + severity: "error", }); } }); }); - } + }, }); return issues; - } + }, }); } // Rule: Require descriptions for types and fields - if (enabledRules['require-description']) { + if (enabledRules["require-description"]) { allRules.push({ - name: 'require-description', - severity: 'warn' as const, + name: "require-description", + severity: "warn" as const, check: (ast: DocumentNode): GraphQLLintIssue[] => { const issues: GraphQLLintIssue[] = []; visit(ast, { @@ -148,12 +162,12 @@ export class GraphQLLinterService { column: node.loc.startToken.column, endLine: node.loc.endToken.line, endColumn: node.loc.endToken.column, - rule: 'require-description', - severity: 'warn' + rule: "require-description", + severity: "warn", }); } - node.fields?.forEach(field => { + node.fields?.forEach((field) => { if (!field.description && field.loc) { issues.push({ message: `Field '${field.name.value}' in type '${node.name.value}' should have a description`, @@ -161,57 +175,61 @@ export class GraphQLLinterService { column: field.loc.startToken.column, endLine: field.loc.endToken.line, endColumn: field.loc.endToken.column, - rule: 'require-description', - severity: 'warn' + rule: "require-description", + severity: "warn", }); } }); - } + }, }); return issues; - } + }, }); } // Rule: Check for deprecated fields without reason - if (enabledRules['require-deprecation-reason']) { + if (enabledRules["require-deprecation-reason"]) { allRules.push({ - name: 'require-deprecation-reason', - severity: 'warn' as const, + name: "require-deprecation-reason", + severity: "warn" as const, check: (ast: DocumentNode): GraphQLLintIssue[] => { const issues: GraphQLLintIssue[] = []; visit(ast, { FieldDefinition(node: FieldDefinitionNode) { - const deprecatedDirective = node.directives?.find(d => d.name.value === 'deprecated'); + const deprecatedDirective = node.directives?.find( + (d) => d.name.value === "deprecated", + ); if (deprecatedDirective && node.loc) { - const reasonArg = deprecatedDirective.arguments?.find(arg => arg.name.value === 'reason'); + const reasonArg = deprecatedDirective.arguments?.find( + (arg) => arg.name.value === "reason", + ); if (!reasonArg) { issues.push({ - message: 'Deprecated fields should include a reason', + message: "Deprecated fields should include a reason", line: node.loc.startToken.line, column: node.loc.startToken.column, endLine: node.loc.endToken.line, endColumn: node.loc.endToken.column, - rule: 'require-deprecation-reason', - severity: 'warn' + rule: "require-deprecation-reason", + severity: "warn", }); } } - } + }, }); return issues; - } + }, }); } // Rule: Enforce camelCase for field names - if (enabledRules['field-naming-convention']) { + if (enabledRules["field-naming-convention"]) { allRules.push({ - name: 'field-naming-convention', - severity: 'warn' as const, + name: "field-naming-convention", + severity: "warn" as const, check: (ast: DocumentNode): GraphQLLintIssue[] => { const issues: GraphQLLintIssue[] = []; - + // Helper function to check if string is camelCase const isCamelCase = (str: string): boolean => { return /^[a-z][a-zA-Z0-9]*$/.test(str); @@ -220,64 +238,300 @@ export class GraphQLLinterService { visit(ast, { FieldDefinition(node: FieldDefinitionNode) { const fieldName = node.name.value; - + // Skip if it's already camelCase or if it's a special field (like __typename) - if (!isCamelCase(fieldName) && !fieldName.startsWith('__') && node.loc) { + if ( + !isCamelCase(fieldName) && + !fieldName.startsWith("__") && + node.loc + ) { issues.push({ message: `Field '${fieldName}' should use camelCase naming convention`, line: node.loc.startToken.line, column: node.loc.startToken.column, endLine: node.loc.endToken.line, endColumn: node.loc.endToken.column, - rule: 'field-naming-convention', - severity: 'warn' + rule: "field-naming-convention", + severity: "warn", }); } - } + }, }); return issues; - } + }, }); } // Rule: Require nullable fields in root operation types - if (enabledRules['root-fields-nullable']) { + if (enabledRules["root-fields-nullable"]) { allRules.push({ - name: 'root-fields-nullable', - severity: 'warn' as const, + name: "root-fields-nullable", + severity: "warn" as const, check: (ast: DocumentNode): GraphQLLintIssue[] => { const issues: GraphQLLintIssue[] = []; - + visit(ast, { ObjectTypeDefinition(node: ObjectTypeDefinitionNode) { // Check if this is a root operation type (Query, Mutation, Subscription) const typeName = node.name.value; - const isRootType = typeName === 'Query' || typeName === 'Mutation' || typeName === 'Subscription'; - + const isRootType = + typeName === "Query" || + typeName === "Mutation" || + typeName === "Subscription"; + if (isRootType && node.fields) { - node.fields.forEach(field => { + node.fields.forEach((field) => { // Check if the field type is non-nullable (ends with !) const fieldType = field.type; - + // If the field type is a NonNullType, it should be nullable and should be flagged - if (fieldType.kind === 'NonNullType' && field.loc) { + if (fieldType.kind === "NonNullType" && field.loc) { issues.push({ message: `Field '${field.name.value}' in root type '${typeName}' should be nullable for better error handling`, line: field.loc.startToken.line, column: field.loc.startToken.column, endLine: field.loc.endToken.line, endColumn: field.loc.endToken.column, - rule: 'root-fields-nullable', - severity: 'warn' + rule: "root-fields-nullable", + severity: "warn", }); } }); } - } + }, }); - + return issues; - } + }, + }); + } + + // Rule: Connection types must have edges and pageInfo fields + if (enabledRules["connection-structure"]) { + allRules.push({ + name: "connection-structure", + severity: "error" as const, + check: (ast: DocumentNode): GraphQLLintIssue[] => { + const issues: GraphQLLintIssue[] = []; + + visit(ast, { + ObjectTypeDefinition(node: ObjectTypeDefinitionNode) { + if (node.name.value.endsWith("Connection")) { + const fieldNames = node.fields?.map((f) => f.name.value) || []; + const hasEdges = fieldNames.includes("edges"); + const hasPageInfo = fieldNames.includes("pageInfo"); + + if (!hasEdges && node.loc) { + issues.push({ + message: `Connection type '${node.name.value}' must have an 'edges' field`, + line: node.loc.startToken.line, + column: node.loc.startToken.column, + endLine: node.loc.endToken.line, + endColumn: node.loc.endToken.column, + rule: "connection-structure", + severity: "error", + }); + } + + if (!hasPageInfo && node.loc) { + issues.push({ + message: `Connection type '${node.name.value}' must have a 'pageInfo' field`, + line: node.loc.startToken.line, + column: node.loc.startToken.column, + endLine: node.loc.endToken.line, + endColumn: node.loc.endToken.column, + rule: "connection-structure", + severity: "error", + }); + } + } + }, + }); + return issues; + }, + }); + } + + // Rule: Edge types must have node and cursor fields + if (enabledRules["edge-structure"]) { + allRules.push({ + name: "edge-structure", + severity: "error" as const, + check: (ast: DocumentNode): GraphQLLintIssue[] => { + const issues: GraphQLLintIssue[] = []; + + visit(ast, { + ObjectTypeDefinition(node: ObjectTypeDefinitionNode) { + if (node.name.value.endsWith("Edge")) { + const fieldNames = node.fields?.map((f) => f.name.value) || []; + const hasNode = fieldNames.includes("node"); + const hasCursor = fieldNames.includes("cursor"); + + if (!hasNode && node.loc) { + issues.push({ + message: `Edge type '${node.name.value}' must have a 'node' field`, + line: node.loc.startToken.line, + column: node.loc.startToken.column, + endLine: node.loc.endToken.line, + endColumn: node.loc.endToken.column, + rule: "edge-structure", + severity: "error", + }); + } + + if (!hasCursor && node.loc) { + issues.push({ + message: `Edge type '${node.name.value}' must have a 'cursor' field`, + line: node.loc.startToken.line, + column: node.loc.startToken.column, + endLine: node.loc.endToken.line, + endColumn: node.loc.endToken.column, + rule: "edge-structure", + severity: "error", + }); + } + } + }, + }); + return issues; + }, + }); + } + + // Rule: Connection fields must accept pagination arguments + if (enabledRules["connection-arguments"]) { + allRules.push({ + name: "connection-arguments", + severity: "warn" as const, + check: (ast: DocumentNode): GraphQLLintIssue[] => { + const issues: GraphQLLintIssue[] = []; + + visit(ast, { + FieldDefinition(node: FieldDefinitionNode) { + // Check if this field returns a Connection type + const returnType = + node.type.kind === "NonNullType" + ? node.type.type.kind === "NamedType" + ? node.type.type.name.value + : node.type.type.kind === "ListType" && + node.type.type.type.kind === "NonNullType" + ? node.type.type.type.type.kind === "NamedType" + ? node.type.type.type.type.name.value + : "Unknown" + : "Unknown" + : node.type.kind === "NamedType" + ? node.type.name.value + : node.type.kind === "ListType" && + node.type.type.kind === "NonNullType" + ? node.type.type.type.kind === "NamedType" + ? node.type.type.type.name.value + : "Unknown" + : "Unknown"; + + if (returnType.endsWith("Connection")) { + const argNames = + node.arguments?.map((arg) => arg.name.value) || []; + const hasFirst = argNames.includes("first"); + const hasAfter = argNames.includes("after"); + + if (!hasFirst && node.loc) { + issues.push({ + message: `Connection field '${node.name.value}' should accept 'first' argument for pagination`, + line: node.loc.startToken.line, + column: node.loc.startToken.column, + endLine: node.loc.endToken.line, + endColumn: node.loc.endToken.column, + rule: "connection-arguments", + severity: "warn", + }); + } + + if (!hasAfter && node.loc) { + issues.push({ + message: `Connection field '${node.name.value}' should accept 'after' argument for pagination`, + line: node.loc.startToken.line, + column: node.loc.startToken.column, + endLine: node.loc.endToken.line, + endColumn: node.loc.endToken.column, + rule: "connection-arguments", + severity: "warn", + }); + } + } + }, + }); + return issues; + }, + }); + } + + // Rule: Pagination arguments should have proper types + if (enabledRules["pagination-argument-types"]) { + allRules.push({ + name: "pagination-argument-types", + severity: "error" as const, + check: (ast: DocumentNode): GraphQLLintIssue[] => { + const issues: GraphQLLintIssue[] = []; + + visit(ast, { + FieldDefinition(node: FieldDefinitionNode) { + node.arguments?.forEach((arg) => { + if (arg.name.value === "first" && arg.loc) { + // Check if it's a non-nullable Int + const isNonNullInt = + arg.type.kind === "NonNullType" && + arg.type.type.kind === "NamedType" && + arg.type.type.name.value === "Int"; + + if (!isNonNullInt) { + const argType = + arg.type.kind === "NonNullType" + ? arg.type.type.kind === "NamedType" + ? arg.type.type.name.value + : "Unknown" + : arg.type.kind === "NamedType" + ? arg.type.name.value + : "Unknown"; + + issues.push({ + message: `'first' argument should be of type 'Int!', got '${argType}'`, + line: arg.loc.startToken.line, + column: arg.loc.startToken.column, + endLine: arg.loc.endToken.line, + endColumn: arg.loc.endToken.column, + rule: "pagination-argument-types", + severity: "error", + }); + } + } + + if (arg.name.value === "after" && arg.loc) { + const argType = + arg.type.kind === "NonNullType" + ? arg.type.type.kind === "NamedType" + ? arg.type.type.name.value + : "Unknown" + : arg.type.kind === "NamedType" + ? arg.type.name.value + : "Unknown"; + + if (argType !== "String") { + issues.push({ + message: `'after' argument should be of type 'String', got '${argType}'`, + line: arg.loc.startToken.line, + column: arg.loc.startToken.column, + endLine: arg.loc.endToken.line, + endColumn: arg.loc.endToken.column, + rule: "pagination-argument-types", + severity: "error", + }); + } + } + }); + }, + }); + return issues; + }, }); } @@ -289,20 +543,20 @@ export class GraphQLLinterService { */ async initialize(): Promise { try { - logger.info('Initializing GraphQL linter service'); - + logger.info("Initializing GraphQL linter service"); + // Reinitialize rules when called (for configuration changes) this.initializeRules(); - + this.isInitialized = true; - logger.info('GraphQL linter service initialized successfully'); + logger.info("GraphQL linter service initialized successfully"); } catch (error) { const stepzenError = new StepZenError( - 'Failed to initialize GraphQL linter service', - 'GRAPHQL_LINT_INIT_ERROR', - error + "Failed to initialize GraphQL linter service", + "GRAPHQL_LINT_INIT_ERROR", + error, ); - logger.error('GraphQL linter initialization failed', stepzenError); + logger.error("GraphQL linter initialization failed", stepzenError); throw stepzenError; } } @@ -327,7 +581,7 @@ export class GraphQLLinterService { } // Read and parse the file - const content = fs.readFileSync(filePath, 'utf8'); + const content = fs.readFileSync(filePath, "utf8"); let ast: DocumentNode; try { @@ -336,10 +590,10 @@ export class GraphQLLinterService { // If parsing fails, create a diagnostic for the parse error const diagnostic = new vscode.Diagnostic( new vscode.Range(0, 0, 0, 0), - `GraphQL parse error: ${parseError instanceof Error ? parseError.message : 'Unknown error'}`, - vscode.DiagnosticSeverity.Error + `GraphQL parse error: ${parseError instanceof Error ? parseError.message : "Unknown error"}`, + vscode.DiagnosticSeverity.Error, ); - diagnostic.source = 'GraphQL Linter'; + diagnostic.source = "GraphQL Linter"; return [diagnostic]; } @@ -351,28 +605,32 @@ export class GraphQLLinterService { } // Convert issues to VS Code diagnostics - const diagnostics: vscode.Diagnostic[] = allIssues.map(issue => { + const diagnostics: vscode.Diagnostic[] = allIssues.map((issue) => { const range = new vscode.Range( issue.line - 1, issue.column - 1, (issue.endLine || issue.line) - 1, - (issue.endColumn || issue.column) - 1 + (issue.endColumn || issue.column) - 1, ); let severity: vscode.DiagnosticSeverity; switch (issue.severity) { - case 'error': + case "error": severity = vscode.DiagnosticSeverity.Error; break; - case 'warn': + case "warn": severity = vscode.DiagnosticSeverity.Warning; break; default: severity = vscode.DiagnosticSeverity.Information; } - const diagnostic = new vscode.Diagnostic(range, issue.message, severity); - diagnostic.source = 'GraphQL Linter'; + const diagnostic = new vscode.Diagnostic( + range, + issue.message, + severity, + ); + diagnostic.source = "GraphQL Linter"; diagnostic.code = issue.rule; return diagnostic; @@ -401,8 +659,8 @@ export class GraphQLLinterService { // Find all GraphQL files in the project const graphqlFiles = await vscode.workspace.findFiles( - new vscode.RelativePattern(projectRoot, '**/*.{graphql,gql}'), - '**/node_modules/**' + new vscode.RelativePattern(projectRoot, "**/*.{graphql,gql}"), + "**/node_modules/**", ); logger.debug(`Found ${graphqlFiles.length} GraphQL files to lint`); @@ -422,14 +680,16 @@ export class GraphQLLinterService { this.diagnosticCollection.forEach(() => { filesWithIssues++; }); - logger.info(`Project linting completed. Found issues in ${filesWithIssues} files`); + logger.info( + `Project linting completed. Found issues in ${filesWithIssues} files`, + ); } catch (error) { const stepzenError = new StepZenError( - 'Failed to lint GraphQL project', - 'GRAPHQL_PROJECT_LINT_ERROR', - error + "Failed to lint GraphQL project", + "GRAPHQL_PROJECT_LINT_ERROR", + error, ); - logger.error('Project linting failed', stepzenError); + logger.error("Project linting failed", stepzenError); throw stepzenError; } } @@ -456,4 +716,4 @@ export class GraphQLLinterService { this.diagnosticCollection.dispose(); this.isInitialized = false; } -} \ No newline at end of file +} diff --git a/src/test/unit/graphqlLinter.test.ts b/src/test/unit/graphqlLinter.test.ts index 94d8037..f278091 100644 --- a/src/test/unit/graphqlLinter.test.ts +++ b/src/test/unit/graphqlLinter.test.ts @@ -3,11 +3,11 @@ * Assisted by CursorAI */ -import * as assert from 'assert'; -import * as vscode from 'vscode'; -import { GraphQLLinterService } from '../../services/graphqlLinter'; -import { overrideServices, resetServices } from '../../services'; -import { parse } from 'graphql'; +import * as assert from "assert"; +import * as vscode from "vscode"; +import { GraphQLLinterService } from "../../services/graphqlLinter"; +import { overrideServices, resetServices } from "../../services"; +import { parse } from "graphql"; suite("GraphQL Linter Test Suite", () => { let linter: GraphQLLinterService; @@ -16,18 +16,18 @@ suite("GraphQL Linter Test Suite", () => { suiteSetup(() => { // Save original logger before overriding - originalLogger = require('../../services').services.logger; + originalLogger = require("../../services").services.logger; // Create mock logger mockLogger = { info: () => {}, debug: () => {}, warn: () => {}, - error: () => {} + error: () => {}, }; // Override services with mocks overrideServices({ - logger: mockLogger + logger: mockLogger, }); linter = new GraphQLLinterService(); @@ -41,12 +41,19 @@ suite("GraphQL Linter Test Suite", () => { test("should initialize GraphQL linter service", async () => { await linter.initialize(); - assert.ok(linter.getDiagnosticCollection(), "Diagnostic collection should be created"); + assert.ok( + linter.getDiagnosticCollection(), + "Diagnostic collection should be created", + ); }); test("should create diagnostic collection with correct name", () => { const collection = linter.getDiagnosticCollection(); - assert.strictEqual(collection.name, 'stepzen-graphql-lint', "Diagnostic collection should have correct name"); + assert.strictEqual( + collection.name, + "stepzen-graphql-lint", + "Diagnostic collection should have correct name", + ); }); test("should clear diagnostics", () => { @@ -68,7 +75,7 @@ suite("GraphQL Linter Test Suite", () => { // Test individual linting rules test("should detect anonymous operations", async () => { await linter.initialize(); - + const content = ` query { user { @@ -77,22 +84,31 @@ suite("GraphQL Linter Test Suite", () => { } } `; - + const ast = parse(content); const rules = (linter as any).rules; - const anonymousRule = rules.find((r: any) => r.name === 'no-anonymous-operations'); - + const anonymousRule = rules.find( + (r: any) => r.name === "no-anonymous-operations", + ); + assert.ok(anonymousRule, "Anonymous operations rule should exist"); - + const issues = anonymousRule.check(ast); - assert.strictEqual(issues.length, 1, "Should detect one anonymous operation"); - assert.strictEqual(issues[0].message, 'Anonymous operations are not allowed. Please provide a name for this operation.'); - assert.strictEqual(issues[0].severity, 'error'); + assert.strictEqual( + issues.length, + 1, + "Should detect one anonymous operation", + ); + assert.strictEqual( + issues[0].message, + "Anonymous operations are not allowed. Please provide a name for this operation.", + ); + assert.strictEqual(issues[0].severity, "error"); }); test("should allow named operations", async () => { await linter.initialize(); - + const content = ` query GetUser { user { @@ -101,18 +117,24 @@ suite("GraphQL Linter Test Suite", () => { } } `; - + const ast = parse(content); const rules = (linter as any).rules; - const anonymousRule = rules.find((r: any) => r.name === 'no-anonymous-operations'); - + const anonymousRule = rules.find( + (r: any) => r.name === "no-anonymous-operations", + ); + const issues = anonymousRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not detect issues with named operations"); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with named operations", + ); }); test("should detect duplicate fields", async () => { await linter.initialize(); - + const content = ` type User { id: ID! @@ -120,47 +142,71 @@ suite("GraphQL Linter Test Suite", () => { name: String! # Duplicate field } `; - + const ast = parse(content); const rules = (linter as any).rules; - const duplicateRule = rules.find((r: any) => r.name === 'no-duplicate-fields'); - + const duplicateRule = rules.find( + (r: any) => r.name === "no-duplicate-fields", + ); + assert.ok(duplicateRule, "Duplicate fields rule should exist"); - + const issues = duplicateRule.check(ast); // Should detect at least one duplicate field issue - assert.ok(issues.length >= 1, `Should detect at least one duplicate field, got ${issues.length}`); - assert.ok(issues.some((d: any) => d.message.includes("Duplicate field 'name' found in type 'User'"))); - assert.strictEqual(issues[0].severity, 'error'); + assert.ok( + issues.length >= 1, + `Should detect at least one duplicate field, got ${issues.length}`, + ); + assert.ok( + issues.some((d: any) => + d.message.includes("Duplicate field 'name' found in type 'User'"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); }); test("should detect missing descriptions", async () => { await linter.initialize(); - + const content = ` type User { id: ID! name: String! } `; - + const ast = parse(content); const rules = (linter as any).rules; - const descriptionRule = rules.find((r: any) => r.name === 'require-description'); - + const descriptionRule = rules.find( + (r: any) => r.name === "require-description", + ); + assert.ok(descriptionRule, "Require description rule should exist"); - + const issues = descriptionRule.check(ast); // Should detect at least two missing descriptions (type and at least one field) - assert.ok(issues.length >= 2, `Should detect at least two missing descriptions, got ${issues.length}`); - assert.ok(issues.some((d: any) => d.message.includes("Type 'User' should have a description"))); - assert.ok(issues.some((d: any) => d.message.includes("Field 'id' in type 'User' should have a description"))); - assert.strictEqual(issues[0].severity, 'warn'); + assert.ok( + issues.length >= 2, + `Should detect at least two missing descriptions, got ${issues.length}`, + ); + assert.ok( + issues.some((d: any) => + d.message.includes("Type 'User' should have a description"), + ), + ); + assert.ok( + issues.some((d: any) => + d.message.includes( + "Field 'id' in type 'User' should have a description", + ), + ), + ); + assert.strictEqual(issues[0].severity, "warn"); }); test("should allow descriptions", async () => { await linter.initialize(); - + const content = ` """A user in the system""" type User { @@ -170,18 +216,24 @@ suite("GraphQL Linter Test Suite", () => { name: String! } `; - + const ast = parse(content); const rules = (linter as any).rules; - const descriptionRule = rules.find((r: any) => r.name === 'require-description'); - + const descriptionRule = rules.find( + (r: any) => r.name === "require-description", + ); + const issues = descriptionRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not detect issues with proper descriptions"); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with proper descriptions", + ); }); test("should detect deprecated fields without reason", async () => { await linter.initialize(); - + const content = ` type User { id: ID! @@ -189,22 +241,31 @@ suite("GraphQL Linter Test Suite", () => { oldField: String! } `; - + const ast = parse(content); const rules = (linter as any).rules; - const deprecatedRule = rules.find((r: any) => r.name === 'require-deprecation-reason'); - + const deprecatedRule = rules.find( + (r: any) => r.name === "require-deprecation-reason", + ); + assert.ok(deprecatedRule, "Require deprecation reason rule should exist"); - + const issues = deprecatedRule.check(ast); - assert.strictEqual(issues.length, 1, "Should detect deprecated field without reason"); - assert.strictEqual(issues[0].message, 'Deprecated fields should include a reason'); - assert.strictEqual(issues[0].severity, 'warn'); + assert.strictEqual( + issues.length, + 1, + "Should detect deprecated field without reason", + ); + assert.strictEqual( + issues[0].message, + "Deprecated fields should include a reason", + ); + assert.strictEqual(issues[0].severity, "warn"); }); test("should allow deprecated fields with reason", async () => { await linter.initialize(); - + const content = ` type User { id: ID! @@ -212,18 +273,24 @@ suite("GraphQL Linter Test Suite", () => { oldField: String! } `; - + const ast = parse(content); const rules = (linter as any).rules; - const deprecatedRule = rules.find((r: any) => r.name === 'require-deprecation-reason'); - + const deprecatedRule = rules.find( + (r: any) => r.name === "require-deprecation-reason", + ); + const issues = deprecatedRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not detect issues with deprecated fields that have reasons"); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with deprecated fields that have reasons", + ); }); test("should detect non-camelCase field names", async () => { await linter.initialize(); - + const content = ` type User { id: ID! @@ -232,23 +299,41 @@ suite("GraphQL Linter Test Suite", () => { email: String! # camelCase - should be fine } `; - + const ast = parse(content); const rules = (linter as any).rules; - const namingRule = rules.find((r: any) => r.name === 'field-naming-convention'); - + const namingRule = rules.find( + (r: any) => r.name === "field-naming-convention", + ); + assert.ok(namingRule, "Field naming convention rule should exist"); - + const issues = namingRule.check(ast); - assert.strictEqual(issues.length, 2, "Should detect non-camelCase field names"); - assert.ok(issues.some((d: any) => d.message.includes("Field 'first_name' should use camelCase naming convention"))); - assert.ok(issues.some((d: any) => d.message.includes("Field 'LastName' should use camelCase naming convention"))); - assert.strictEqual(issues[0].severity, 'warn'); + assert.strictEqual( + issues.length, + 2, + "Should detect non-camelCase field names", + ); + assert.ok( + issues.some((d: any) => + d.message.includes( + "Field 'first_name' should use camelCase naming convention", + ), + ), + ); + assert.ok( + issues.some((d: any) => + d.message.includes( + "Field 'LastName' should use camelCase naming convention", + ), + ), + ); + assert.strictEqual(issues[0].severity, "warn"); }); test("should allow camelCase field names", async () => { await linter.initialize(); - + const content = ` type User { id: ID! @@ -257,101 +342,143 @@ suite("GraphQL Linter Test Suite", () => { emailAddress: String! } `; - + const ast = parse(content); const rules = (linter as any).rules; - const namingRule = rules.find((r: any) => r.name === 'field-naming-convention'); - + const namingRule = rules.find( + (r: any) => r.name === "field-naming-convention", + ); + const issues = namingRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not detect issues with camelCase field names"); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with camelCase field names", + ); }); test("should ignore special fields like __typename", async () => { await linter.initialize(); - + const content = ` type User { id: ID! __typename: String! # Special field - should be ignored } `; - + const ast = parse(content); const rules = (linter as any).rules; - const namingRule = rules.find((r: any) => r.name === 'field-naming-convention'); - + const namingRule = rules.find( + (r: any) => r.name === "field-naming-convention", + ); + const issues = namingRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not detect issues with special fields like __typename"); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with special fields like __typename", + ); }); test("should detect non-nullable fields in root types", async () => { await linter.initialize(); - + const content = ` type Query { user(id: ID!): User! # Non-nullable return type users: [User!]! # Non-nullable list } `; - + const ast = parse(content); const rules = (linter as any).rules; - const nullableRule = rules.find((r: any) => r.name === 'root-fields-nullable'); - + const nullableRule = rules.find( + (r: any) => r.name === "root-fields-nullable", + ); + assert.ok(nullableRule, "Root fields nullable rule should exist"); - + const issues = nullableRule.check(ast); - assert.strictEqual(issues.length, 2, "Should detect non-nullable fields in root types"); - assert.ok(issues.some((d: any) => d.message.includes("Field 'user' in root type 'Query' should be nullable"))); - assert.ok(issues.some((d: any) => d.message.includes("Field 'users' in root type 'Query' should be nullable"))); - assert.strictEqual(issues[0].severity, 'warn'); + assert.strictEqual( + issues.length, + 2, + "Should detect non-nullable fields in root types", + ); + assert.ok( + issues.some((d: any) => + d.message.includes( + "Field 'user' in root type 'Query' should be nullable", + ), + ), + ); + assert.ok( + issues.some((d: any) => + d.message.includes( + "Field 'users' in root type 'Query' should be nullable", + ), + ), + ); + assert.strictEqual(issues[0].severity, "warn"); }); test("should allow nullable fields in root types", async () => { await linter.initialize(); - + const content = ` type Query { user(id: ID!): User # Nullable return type users: [User] # Nullable list } `; - + const ast = parse(content); const rules = (linter as any).rules; - const nullableRule = rules.find((r: any) => r.name === 'root-fields-nullable'); - + const nullableRule = rules.find( + (r: any) => r.name === "root-fields-nullable", + ); + const issues = nullableRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not detect issues with nullable fields in root types"); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with nullable fields in root types", + ); }); test("should not check non-root types for nullability", async () => { await linter.initialize(); - + const content = ` type User { id: ID! # Non-nullable in regular type - should be fine name: String! # Non-nullable in regular type - should be fine } `; - + const ast = parse(content); const rules = (linter as any).rules; - const nullableRule = rules.find((r: any) => r.name === 'root-fields-nullable'); - + const nullableRule = rules.find( + (r: any) => r.name === "root-fields-nullable", + ); + const issues = nullableRule.check(ast); - assert.strictEqual(issues.length, 0, "Should not check nullability for non-root types"); + assert.strictEqual( + issues.length, + 0, + "Should not check nullability for non-root types", + ); }); test("should handle GraphQL parse errors gracefully", async () => { await linter.initialize(); - + const content = ` type User { id: ID! name: String! # Missing closing brace `; - + try { parse(content); assert.fail("Should have thrown a parse error"); @@ -362,29 +489,33 @@ suite("GraphQL Linter Test Suite", () => { test("should handle non-existent files gracefully", async () => { await linter.initialize(); - const diagnostics = await linter.lintFile('/non/existent/file.graphql'); - assert.strictEqual(diagnostics.length, 0, "Should return empty array for non-existent files"); + const diagnostics = await linter.lintFile("/non/existent/file.graphql"); + assert.strictEqual( + diagnostics.length, + 0, + "Should return empty array for non-existent files", + ); }); test("should respect configuration changes", async () => { // Test with anonymous operations rule disabled const originalGetConfiguration = vscode.workspace.getConfiguration; vscode.workspace.getConfiguration = (section?: string) => { - if (section === 'stepzen') { + if (section === "stepzen") { return { get: (key: string, defaultValue: any) => { - if (key === 'graphqlLintRules') { + if (key === "graphqlLintRules") { return { - 'no-anonymous-operations': false, // Disabled - 'no-duplicate-fields': true, - 'require-description': true, - 'require-deprecation-reason': true, - 'field-naming-convention': true, - 'root-fields-nullable': true + "no-anonymous-operations": false, // Disabled + "no-duplicate-fields": true, + "require-description": true, + "require-deprecation-reason": true, + "field-naming-convention": true, + "root-fields-nullable": true, }; } return defaultValue; - } + }, } as any; } return originalGetConfiguration(section); @@ -393,12 +524,18 @@ suite("GraphQL Linter Test Suite", () => { try { // Reinitialize with new configuration await linter.initialize(); - + const rules = (linter as any).rules; - const anonymousRule = rules.find((r: any) => r.name === 'no-anonymous-operations'); - + const anonymousRule = rules.find( + (r: any) => r.name === "no-anonymous-operations", + ); + // Rule should not exist when disabled - assert.strictEqual(anonymousRule, undefined, "Anonymous operations rule should not exist when disabled"); + assert.strictEqual( + anonymousRule, + undefined, + "Anonymous operations rule should not exist when disabled", + ); } finally { // Restore original method vscode.workspace.getConfiguration = originalGetConfiguration; @@ -408,21 +545,25 @@ suite("GraphQL Linter Test Suite", () => { test("should handle all rules disabled", async () => { const originalGetConfiguration = vscode.workspace.getConfiguration; vscode.workspace.getConfiguration = (section?: string) => { - if (section === 'stepzen') { + if (section === "stepzen") { return { get: (key: string, defaultValue: any) => { - if (key === 'graphqlLintRules') { + if (key === "graphqlLintRules") { return { - 'no-anonymous-operations': false, - 'no-duplicate-fields': false, - 'require-description': false, - 'require-deprecation-reason': false, - 'field-naming-convention': false, - 'root-fields-nullable': false + "no-anonymous-operations": false, + "no-duplicate-fields": false, + "require-description": false, + "require-deprecation-reason": false, + "field-naming-convention": false, + "root-fields-nullable": false, + "connection-structure": false, + "edge-structure": false, + "connection-arguments": false, + "pagination-argument-types": false, }; } return defaultValue; - } + }, } as any; } return originalGetConfiguration(section); @@ -430,12 +571,441 @@ suite("GraphQL Linter Test Suite", () => { try { await linter.initialize(); - + const rules = (linter as any).rules; - - assert.strictEqual(rules.length, 0, "Should not have any rules when all are disabled"); + + assert.strictEqual( + rules.length, + 0, + "Should not have any rules when all are disabled", + ); } finally { vscode.workspace.getConfiguration = originalGetConfiguration; } }); -}); \ No newline at end of file + + // Helper function for pagination tests + async function withPaginationRules( + testFn: () => Promise, + ): Promise { + const originalGetConfiguration = vscode.workspace.getConfiguration; + vscode.workspace.getConfiguration = (section?: string) => { + if (section === "stepzen") { + return { + get: (key: string, defaultValue: any) => { + if (key === "graphqlLintRules") { + return { + "no-anonymous-operations": false, + "no-duplicate-fields": false, + "require-description": false, + "require-deprecation-reason": false, + "field-naming-convention": false, + "root-fields-nullable": false, + "connection-structure": true, + "edge-structure": true, + "connection-arguments": true, + "pagination-argument-types": true, + }; + } + return defaultValue; + }, + } as any; + } + return originalGetConfiguration(section); + }; + + try { + await testFn(); + } finally { + vscode.workspace.getConfiguration = originalGetConfiguration; + } + } + + // Pagination rule tests + test("should detect Connection types missing edges field", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type UserConnection { + pageInfo: PageInfo! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionRule = rules.find( + (r: any) => r.name === "connection-structure", + ); + + assert.ok(connectionRule, "Connection structure rule should exist"); + + const issues = connectionRule.check(ast); + assert.ok(issues.length >= 1, "Should detect missing edges field"); + assert.ok( + issues.some((d: any) => + d.message.includes("must have an 'edges' field"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should detect Connection types missing pageInfo field", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type UserConnection { + edges: [UserEdge!]! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionRule = rules.find( + (r: any) => r.name === "connection-structure", + ); + + const issues = connectionRule.check(ast); + assert.ok(issues.length >= 1, "Should detect missing pageInfo field"); + assert.ok( + issues.some((d: any) => + d.message.includes("must have a 'pageInfo' field"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should allow valid Connection types", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type UserConnection { + edges: [UserEdge!]! + pageInfo: PageInfo! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionRule = rules.find( + (r: any) => r.name === "connection-structure", + ); + + const issues = connectionRule.check(ast); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with valid Connection type", + ); + }); + }); + + test("should detect Edge types missing node field", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type UserEdge { + cursor: String! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const edgeRule = rules.find((r: any) => r.name === "edge-structure"); + + assert.ok(edgeRule, "Edge structure rule should exist"); + + const issues = edgeRule.check(ast); + assert.ok(issues.length >= 1, "Should detect missing node field"); + assert.ok( + issues.some((d: any) => d.message.includes("must have a 'node' field")), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should detect Edge types missing cursor field", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type UserEdge { + node: User! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const edgeRule = rules.find((r: any) => r.name === "edge-structure"); + + const issues = edgeRule.check(ast); + assert.ok(issues.length >= 1, "Should detect missing cursor field"); + assert.ok( + issues.some((d: any) => + d.message.includes("must have a 'cursor' field"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should allow valid Edge types", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type UserEdge { + node: User! + cursor: String! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const edgeRule = rules.find((r: any) => r.name === "edge-structure"); + + const issues = edgeRule.check(ast); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with valid Edge type", + ); + }); + }); + + test("should detect Connection fields missing first argument", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(after: String): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionArgsRule = rules.find( + (r: any) => r.name === "connection-arguments", + ); + + assert.ok(connectionArgsRule, "Connection arguments rule should exist"); + + const issues = connectionArgsRule.check(ast); + assert.ok(issues.length >= 1, "Should detect missing first argument"); + assert.ok( + issues.some((d: any) => + d.message.includes("should accept 'first' argument"), + ), + ); + assert.strictEqual(issues[0].severity, "warn"); + }); + }); + + test("should detect Connection fields missing after argument", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(first: Int!): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionArgsRule = rules.find( + (r: any) => r.name === "connection-arguments", + ); + + const issues = connectionArgsRule.check(ast); + assert.ok(issues.length >= 1, "Should detect missing after argument"); + assert.ok( + issues.some((d: any) => + d.message.includes("should accept 'after' argument"), + ), + ); + assert.strictEqual(issues[0].severity, "warn"); + }); + }); + + test("should allow Connection fields with proper arguments", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(first: Int!, after: String): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionArgsRule = rules.find( + (r: any) => r.name === "connection-arguments", + ); + + const issues = connectionArgsRule.check(ast); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with proper pagination arguments", + ); + }); + }); + + test("should detect incorrect first argument type", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(first: String, after: String): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const paginationTypesRule = rules.find( + (r: any) => r.name === "pagination-argument-types", + ); + + assert.ok( + paginationTypesRule, + "Pagination argument types rule should exist", + ); + + const issues = paginationTypesRule.check(ast); + assert.ok( + issues.length >= 1, + "Should detect incorrect first argument type", + ); + assert.ok( + issues.some((d: any) => + d.message.includes("'first' argument should be of type 'Int!'"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should detect incorrect after argument type", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(first: Int, after: Int): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const paginationTypesRule = rules.find( + (r: any) => r.name === "pagination-argument-types", + ); + + const issues = paginationTypesRule.check(ast); + assert.ok( + issues.length >= 1, + "Should detect incorrect after argument type", + ); + assert.ok( + issues.some((d: any) => + d.message.includes("'after' argument should be of type 'String'"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should detect nullable first argument type", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(first: Int, after: String): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const paginationTypesRule = rules.find( + (r: any) => r.name === "pagination-argument-types", + ); + + assert.ok( + paginationTypesRule, + "Pagination argument types rule should exist", + ); + + const issues = paginationTypesRule.check(ast); + assert.ok( + issues.length >= 1, + "Should detect nullable first argument type", + ); + assert.ok( + issues.some((d: any) => + d.message.includes("'first' argument should be of type 'Int!'"), + ), + ); + assert.strictEqual(issues[0].severity, "error"); + }); + }); + + test("should allow correct pagination argument types", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + users(first: Int!, after: String): UserConnection! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const paginationTypesRule = rules.find( + (r: any) => r.name === "pagination-argument-types", + ); + + const issues = paginationTypesRule.check(ast); + assert.strictEqual( + issues.length, + 0, + "Should not detect issues with correct pagination argument types", + ); + }); + }); + + test("should not check non-Connection fields for pagination arguments", async () => { + await withPaginationRules(async () => { + await linter.initialize(); + + const content = ` + type Query { + user(id: ID!): User! + } + `; + + const ast = parse(content); + const rules = (linter as any).rules; + const connectionArgsRule = rules.find( + (r: any) => r.name === "connection-arguments", + ); + + const issues = connectionArgsRule.check(ast); + assert.strictEqual( + issues.length, + 0, + "Should not check non-Connection fields for pagination arguments", + ); + }); + }); +});