diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..ee4dce4 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,168 @@ + + +# Schema Indexing Architecture + +This document describes the modular architecture of the StepZen VS Code extension's schema indexing system. + +## Overview + +The schema indexing system has been refactored from a monolithic `stepzenProjectScanner.ts` file into a modular, service-oriented architecture. This improves maintainability, testability, and separation of concerns. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Extension Layer │ +├─────────────────────────────────────────────────────────────────┤ +│ Commands │ Panels │ Utils │ Language Features │ +├─────────────────────────────────────────────────────────────────┤ +│ Service Registry │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ SchemaIndexSvc │ │ ProjectResolver │ │ StepzenCliSvc │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ Schema Processing Layer │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │ +│ │ SchemaIndexer │ │ SchemaLinker │ │ SchemaParser │ │ +│ │ (indexer.ts) │ │ (linker.ts) │ │ (parser.ts) │ │ +│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │ +├─────────────────────────────────────────────────────────────────┤ +│ GraphQL & File System │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Module Structure + +### Service Layer + +#### `services/SchemaIndexService.ts` + +- **Purpose**: High-level orchestration service for schema indexing +- **Responsibilities**: + - Coordinates the scanning process + - Provides progress notifications to users + - Exposes public API for schema information retrieval + - Manages the lifecycle of indexing operations + +#### `services/index.ts` + +- **Purpose**: Service registry for dependency injection +- **Responsibilities**: + - Maintains singleton instances of all services + - Provides service override capabilities for testing + - Acts as the central service locator + +### Schema Processing Layer + +#### `services/schema/indexer.ts` + +- **Purpose**: Core indexing logic for GraphQL schema definitions +- **Responsibilities**: + - Builds definition indexes from parsed GraphQL documents + - Tracks root operations (Query, Mutation, Subscription fields) + - Maintains field indexes for all types + - Processes SDL executables and persisted documents + - Manages type relationships and directives + +#### `services/schema/linker.ts` + +- **Purpose**: SDL include directive traversal +- **Responsibilities**: + - Safely traverses `@sdl(files: [...])` directives + - Prevents infinite loops in file inclusion + - Returns ordered list of all schema files to process + +#### `services/schema/parser.ts` + +- **Purpose**: GraphQL AST utility functions +- **Responsibilities**: + - Type unwrapping (removing NonNull/List wrappers) + - Full type string generation with nullability + - List type detection + - Scalar type identification + +### Migration Complete + +The original `utils/stepzenProjectScanner.ts` compatibility layer has been removed. All components now use the new service architecture directly through `services.schemaIndex`. + +## Data Flow + +1. **Initialization**: Extension activates and creates service instances +2. **Project Detection**: ProjectResolver finds StepZen configuration +3. **Schema Scanning**: SchemaIndexService orchestrates the process: + - SchemaLinker traverses SDL includes to find all files + - Each file is parsed using GraphQL parser + - SchemaIndexer processes each document to build indexes + - SDL executables are scanned for operation metadata +4. **Information Retrieval**: Commands and features query the service for: + - Symbol definitions (go-to-definition) + - Root operations (code lens, request execution) + - Type relationships (visualization) + - Persisted documents (request execution) + +## Key Benefits + +### Modularity + +- Each module has a single, well-defined responsibility +- Dependencies are explicit and injected +- Easy to understand and modify individual components + +### Testability + +- Services can be mocked independently +- Each module can be unit tested in isolation +- Service registry supports test overrides + +### Maintainability + +- Clear separation of concerns +- Reduced coupling between components +- Easier to add new features or modify existing ones + +### Performance + +- Efficient file traversal with loop detection +- Incremental indexing capabilities +- Optimized data structures for fast lookups + +## Migration Strategy + +The refactoring has been completed in phases: + +1. **Phase 1** ✅ **Complete**: New architecture with compatibility layer + + - Created modular service architecture + - Maintained backward compatibility + - All tests remained green + +2. **Phase 2** ✅ **Complete**: Direct service usage + + - Migrated all commands and features to use services directly + - Updated imports across the codebase + - Removed dependency on compatibility layer + +3. **Phase 3** ✅ **Complete**: Cleanup + - Removed compatibility layer entirely + - Full migration to service-oriented architecture achieved + +## Testing Strategy + +- **Unit Tests**: Each module tested independently with mocked dependencies +- **Integration Tests**: Service interactions tested with real implementations +- **Compatibility Tests**: Ensure backward compatibility is maintained +- **Performance Tests**: Verify indexing performance with large schemas + +## Error Handling + +- Consistent error handling across all modules +- Graceful degradation when files are missing or malformed +- Detailed logging for debugging and troubleshooting +- User-friendly progress notifications and error messages + +--- + +_Portions of the Content may be generated with the assistance of CursorAI_ diff --git a/src/commands/addMaterializer.ts b/src/commands/addMaterializer.ts index f761b34..e8f44a4 100644 --- a/src/commands/addMaterializer.ts +++ b/src/commands/addMaterializer.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode'; -import { getRootOperationsMap } from '../utils/stepzenProjectScanner'; +// Removed import - now using services.schemaIndex directly import { services } from '../services'; import { handleError } from '../errors'; @@ -50,7 +50,7 @@ export async function addMaterializer() { const baseType = declaredType.replace(/[[\]!]/g, ''); // Remove [] and ! characters // Find matching root operations (queries) that return the same type - const ops = Object.entries(getRootOperationsMap()).filter(([_opName, info]) => { + const ops = Object.entries(services.schemaIndex.getRootOperations()).filter(([_opName, info]) => { return info.returnType === baseType && info.isList === isList; }); @@ -79,7 +79,7 @@ export async function addMaterializer() { services.logger.info(`Selected operation: ${chosen}`); // Get argument names - const argNames = getRootOperationsMap()[chosen].args; + const argNames = services.schemaIndex.getRootOperations()[chosen].args; // Build directive snippet with proper indentation const indentUnit = editor.options.insertSpaces diff --git a/src/commands/generateOperations.ts b/src/commands/generateOperations.ts index f7eaab4..815c536 100644 --- a/src/commands/generateOperations.ts +++ b/src/commands/generateOperations.ts @@ -6,12 +6,7 @@ import * as vscode from "vscode"; import * as path from "path"; import * as fs from "fs"; -import { - getRootOperationsMap, - getFieldIndex, - scanStepZenProject, - ArgInfo, -} from "../utils/stepzenProjectScanner"; +import type { ArgInfo } from "../services/schema/indexer"; import { resolveStepZenProjectRoot } from "../utils/stepzenProject"; import { services } from "../services"; import { FILE_PATTERNS, GRAPHQL, MESSAGES, TEMP_FILE_PATTERNS } from "../utils/constants"; @@ -58,11 +53,11 @@ export async function generateOperations() { } // Scan the project to get the latest schema information - await scanStepZenProject(indexPath); + await services.schemaIndex.scan(indexPath); // Get the root operations and field index - const rootOps = getRootOperationsMap(); - const fieldIdx = getFieldIndex(); + const rootOps = services.schemaIndex.getRootOperations(); + const fieldIdx = services.schemaIndex.getFieldIndex(); // Debug logging services.logger.debug( diff --git a/src/commands/goToDefinition.ts b/src/commands/goToDefinition.ts index 3210bd9..b4a4a81 100644 --- a/src/commands/goToDefinition.ts +++ b/src/commands/goToDefinition.ts @@ -4,7 +4,7 @@ */ import * as vscode from "vscode"; -import { findDefinition } from "../utils/stepzenProjectScanner"; +// Removed import - now using services.schemaIndex directly import { services } from "../services"; import { handleError } from "../errors"; @@ -38,7 +38,7 @@ export async function goToDefinition() { const token = document.getText(wordRange); services.logger.info(`Searching for definition of symbol: "${token}"`); - const locations = findDefinition(token); + const locations = services.schemaIndex.findDefinition(token); if (!locations || locations.length === 0) { vscode.window.showWarningMessage(`No definition found for "${token}".`); services.logger.warn(`No definition found for "${token}".`); diff --git a/src/commands/runRequest.ts b/src/commands/runRequest.ts index bd95e14..926246f 100644 --- a/src/commands/runRequest.ts +++ b/src/commands/runRequest.ts @@ -8,7 +8,7 @@ import * as vscode from "vscode"; import { parse, NamedTypeNode, OperationDefinitionNode } from "graphql"; import { clearResultsPanel } from "../panels/resultsPanel"; import { runtimeDiag } from "../extension"; -import { getPersistedDocMap, OperationEntry } from "../utils/stepzenProjectScanner"; +import type { OperationEntry } from "../services/schema/indexer"; import { services } from "../services"; // Import executeStepZenRequest from separate file import { executeStepZenRequest } from "./executeStepZenRequest"; @@ -316,7 +316,7 @@ export async function runPersisted(documentId: string, operationName: string) { return; } - const persistedDocMap = getPersistedDocMap(); + const persistedDocMap = services.schemaIndex.getPersistedDocMap(); if (!persistedDocMap) { vscode.window.showErrorMessage("Persisted document map is not available"); return; diff --git a/src/extension.ts b/src/extension.ts index 1c203a7..8221a89 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,7 +9,7 @@ import * as fs from "fs"; import { StepZenError, handleError } from "./errors"; import { UI, FILE_PATTERNS, COMMANDS, CONFIG_KEYS, MESSAGES, LANGUAGE_IDS } from "./utils/constants"; import { safeRegisterCommand } from "./utils/safeRegisterCommand"; -import { scanStepZenProject, computeHash } from "./utils/stepzenProjectScanner"; +// Removed import - now using services.schemaIndex directly import { StepZenCodeLensProvider } from "./utils/codelensProvider"; import { services } from "./services"; @@ -95,9 +95,9 @@ async function initialiseFor(folder: vscode.WorkspaceFolder) { ); const fullSDL = contents.join('\n'); - lastSchemaHash = computeHash(fullSDL); + lastSchemaHash = services.schemaIndex.computeHash(fullSDL); - await scanStepZenProject(indexPath); + await services.schemaIndex.scan(indexPath); } catch (err) { const error = new StepZenError( "Initial project scan failed", @@ -144,7 +144,7 @@ async function initialiseFor(folder: vscode.WorkspaceFolder) { ); const fullSDL = contents.join('\n'); - const currentHash = computeHash(fullSDL); + const currentHash = services.schemaIndex.computeHash(fullSDL); // Skip parsing if schema hasn't changed if (lastSchemaHash && lastSchemaHash === currentHash) { @@ -153,7 +153,7 @@ async function initialiseFor(folder: vscode.WorkspaceFolder) { } services.logger.info(`Rescanning project after change in ${uri.fsPath}`); - await scanStepZenProject(indexPath); + await services.schemaIndex.scan(indexPath); // Update hash after successful scan lastSchemaHash = currentHash; diff --git a/src/panels/schemaVisualizerPanel.ts b/src/panels/schemaVisualizerPanel.ts index da33dc7..fc63d93 100644 --- a/src/panels/schemaVisualizerPanel.ts +++ b/src/panels/schemaVisualizerPanel.ts @@ -6,15 +6,11 @@ // src/panels/schemaVisualizerPanel.ts import * as vscode from "vscode"; import { Uri } from "vscode"; -import { - getFieldIndex, - getTypeDirectives, - getTypeRelationships, - scanStepZenProject, +import type { FieldInfo, DirectiveInfo, TypeRelationship, -} from "../utils/stepzenProjectScanner"; +} from "../services/schema/indexer"; import { services } from "../services"; import { resolveStepZenProjectRoot } from "../utils/stepzenProject"; import * as path from "path"; @@ -206,7 +202,7 @@ export async function openSchemaVisualizerPanel( * @returns true if schema data was successfully loaded, false otherwise */ async function ensureSchemaDataLoaded(): Promise { - const fieldIndex = getFieldIndex(); + const fieldIndex = services.schemaIndex.getFieldIndex(); // If we already have schema data, return true if (Object.keys(fieldIndex).length > 0) { @@ -248,7 +244,7 @@ async function ensureSchemaDataLoaded(): Promise { // Scan the project services.logger.info(`Scanning StepZen project at ${indexPath}`); - await scanStepZenProject(indexPath); + await services.schemaIndex.scan(indexPath); services.logger.debug("Schema scan completed successfully"); return true; } catch (error) { @@ -264,9 +260,9 @@ async function ensureSchemaDataLoaded(): Promise { * @returns A complete schema model for visualization */ function buildSchemaModel(): SchemaVisualizerModel { - const fieldIndex = getFieldIndex(); - const typeDirectives = getTypeDirectives(); - const relationships = getTypeRelationships(); + const fieldIndex = services.schemaIndex.getFieldIndex(); + const typeDirectives = services.schemaIndex.getTypeDirectives(); + const relationships = services.schemaIndex.getTypeRelationships(); const model: SchemaVisualizerModel = { types: {}, diff --git a/src/services/SchemaIndexService.ts b/src/services/SchemaIndexService.ts new file mode 100644 index 0000000..25eb62e --- /dev/null +++ b/src/services/SchemaIndexService.ts @@ -0,0 +1,178 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import { readFileSync } from "fs"; +import * as path from "path"; +import { parse } from "graphql"; +import * as vscode from 'vscode'; +import { logger } from './logger'; +import { traverseSDLIncludes } from './schema/linker'; +import { SchemaIndexer, SymbolLocation, RootOperationInfo, FieldInfo, DirectiveInfo, TypeRelationship, OperationEntry, PersistedDocEntry } from './schema/indexer'; + +/** + * Service for indexing and querying StepZen GraphQL schemas + * Provides a high-level interface for schema scanning and information retrieval + */ +export class SchemaIndexService { + private indexer: SchemaIndexer = new SchemaIndexer(); + + /** + * Scans a StepZen project starting from an entry file. + * Traverses all linked schema files, builds definitions index, + * and populates operation and type information. + * + * @param entryFile The path to the main entry file (typically index.graphql) + * @returns Promise that resolves when scanning completes + */ + async scan(entryFile: string): Promise { + // Add validation + if (!entryFile || typeof entryFile !== 'string') { + logger.error('Invalid entry file path provided'); + return; + } + + logger.info(`Scanning project starting at ${entryFile}`); + + // Show progress notification to the user + await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "StepZen: Scanning schema...", + cancellable: false + }, async (progress) => { + progress.report({ message: "Starting scan...", increment: 0 }); + + // Clear previous state + this.indexer.clear(); + + // Traverse all linked schema files + const schemaFiles = traverseSDLIncludes(entryFile); + logger.debug(`Found ${schemaFiles.length} schema files to process`); + + let filesProcessed = 0; + + // Index each schema file + for (const file of schemaFiles) { + logger.debug(`Parsing ${file}`); + + try { + const content = readFileSync(file, "utf8"); + const doc = parse(content, { noLocation: false }); + this.indexer.indexDocument(doc, file); + + filesProcessed++; + progress.report({ + message: `Processed ${filesProcessed} files...`, + increment: Math.floor((filesProcessed / schemaFiles.length) * 70) + }); + } catch (err) { + logger.error(`Error processing file ${file}: ${err}`); + } + } + + progress.report({ message: "Building schema...", increment: 80 }); + + // After collecting all schema files, build the full SDL and scan executables + try { + const fullSDL = schemaFiles.map(fp => readFileSync(fp, 'utf8')).join('\n'); + this.indexer.scanSDLExecutables(fullSDL, path.dirname(entryFile)); + } catch (err) { + logger.error(`Error scanning SDL executables: ${err}`); + } + + progress.report({ message: "Schema scan complete", increment: 100 }); + + // Log detailed information about what was found + const rootOperations = this.indexer.getRootOperations(); + const fieldIndex = this.indexer.getFieldIndex(); + + logger.info(`Root operations found: ${Object.keys(rootOperations).length}`); + for (const [fieldName, fieldInfo] of Object.entries(rootOperations)) { + logger.info(` - ${fieldName} (returns: ${fieldInfo.returnType}, list: ${fieldInfo.isList})`); + } + + logger.info(`Types in field index: ${Object.keys(fieldIndex).length}`); + for (const typeName of Object.keys(fieldIndex)) { + logger.info(` ${typeName}: ${fieldIndex[typeName].length} fields`); + } + + logger.info(`Schema scan completed: ${filesProcessed} files processed, ${Object.keys(fieldIndex).length} types found`); + }); + } + + /** + * Clears all scanner state including root operations, operation map, and persisted documents. + * Used for testing to ensure clean state between test runs. + */ + clearState(): void { + this.indexer.clear(); + } + + /** + * Finds all definitions of a symbol in the project. + * @param name The symbol name to find + * @returns Array of locations or undefined if not found + */ + findDefinition(name: string): SymbolLocation[] | undefined { + return this.indexer.findDefinition(name); + } + + /** + * Returns the map of root operations (fields on Query, Mutation, Subscription). + * @returns Record of operation names to operation details + */ + getRootOperations(): Record { + return this.indexer.getRootOperations(); + } + + /** + * Returns the map of GraphQL operations in all scanned files. + * @returns Map of file URIs to operation entries + */ + getOperationMap(): Record { + return this.indexer.getOperationMap(); + } + + /** + * Returns the map of persisted documents. + * @returns Map of document IDs to persisted document entries + */ + getPersistedDocMap(): Record { + return this.indexer.getPersistedDocMap(); + } + + /** + * Returns the index of fields grouped by parent type. + * @returns Record mapping type names to their field information + */ + getFieldIndex(): Record { + return this.indexer.getFieldIndex(); + } + + /** + * Returns directives applied to each type. + * @returns Record mapping type names to their directive information + */ + getTypeDirectives(): Record { + return this.indexer.getTypeDirectives(); + } + + /** + * Returns relationships between types in the schema. + * @returns Array of type relationship objects + */ + getTypeRelationships(): TypeRelationship[] { + return this.indexer.getTypeRelationships(); + } + + /** + * Computes a SHA256 hash of the input string. + * Used for generating stable document IDs. + * @param input The string to hash + * @returns Hex string of the SHA256 hash + */ + computeHash(input: string): string { + return this.indexer.computeHash(input); + } +} \ No newline at end of file diff --git a/src/services/index.ts b/src/services/index.ts index 62127e5..111e2b6 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -1,6 +1,7 @@ import { StepzenCliService } from './cli'; import { Logger, logger } from './logger'; import { ProjectResolver } from './projectResolver'; +import { SchemaIndexService } from './SchemaIndexService'; /** * Service registry for dependency injection of application services @@ -10,6 +11,7 @@ export interface ServiceRegistry { cli: StepzenCliService; logger: Logger; projectResolver: ProjectResolver; + schemaIndex: SchemaIndexService; } /** @@ -19,6 +21,7 @@ export const services: ServiceRegistry = { cli: new StepzenCliService(), logger, projectResolver: new ProjectResolver(logger), + schemaIndex: new SchemaIndexService(), }; /** diff --git a/src/services/schema/indexer.ts b/src/services/schema/indexer.ts new file mode 100644 index 0000000..8d6f60a --- /dev/null +++ b/src/services/schema/indexer.ts @@ -0,0 +1,533 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import { readFileSync, existsSync } from "fs"; +import * as path from "path"; +import { + parse, + visit, + Kind, + DocumentNode, + DefinitionNode, + ObjectTypeDefinitionNode, + ObjectTypeExtensionNode, + FieldDefinitionNode, + OperationDefinitionNode, + FragmentDefinitionNode, + ListValueNode, + ObjectValueNode, + StringValueNode, + BooleanValueNode, + ArgumentNode +} from "graphql"; +import { Uri } from 'vscode'; +import { logger } from '../logger'; +import { GRAPHQL, GraphQLOperationType } from '../../utils/constants'; +import { StepZenError, handleError } from "../../errors"; +import { unwrapType, getFullType, isListType, isScalarType } from './parser'; + +/** Location of a symbol inside a file (0‑based). */ +interface Location { + filePath: string; + line: number; + character: number; +} + +/** Container‑aware location (null container ⇒ type‑level symbol). */ +export interface SymbolLocation extends Location { + container: string | null; +} + +export interface ArgInfo { + name: string; + type: string; +} + +export interface RootOperationInfo { + returnType: string; + isList: boolean; + args: ArgInfo[]; + location: { + uri: string; + range: { + start: { line: number; column: number; }; + end: { line: number; column: number; }; + } | null + }; +} + +export interface OperationEntry { + name: string; + type: 'query' | 'mutation' | 'subscription' | 'fragment'; + fileUri: Uri; + range: { start: number; end: number }; + persisted: boolean; +} + +export interface PersistedDocEntry { + documentId: string; + fileUri: Uri; + operations: OperationEntry[]; +} + +export interface DirectiveInfo { + name: string; + args: { name: string; value: string | null }[]; +} + +export interface FieldInfo { + name: string; + type: string; + isList: boolean; + args: ArgInfo[]; + directives: DirectiveInfo[]; + location: { uri: string; line: number; character: number }; +} + +export interface TypeRelationship { + fromType: string; + toType: string; + fieldName: string; + isList: boolean; +} + +/** + * Builds indexes from GraphQL schema files + */ +export class SchemaIndexer { + private definitionIndex: Map = new Map(); + private rootOperations: Record = {}; + private fieldIndex: Record = {}; + private typeDirectives: Record = {}; + private typeRelationships: TypeRelationship[] = []; + private operationMap: Record = {}; + private persistedDocMap: Record = {}; + + private readonly ROOT_TYPES = new Set(GRAPHQL.ROOT_OPERATION_TYPES); + + /** + * Clears all indexes and maps + */ + clear(): void { + this.definitionIndex.clear(); + this.rootOperations = {}; + this.fieldIndex = {}; + this.typeDirectives = {}; + this.typeRelationships = []; + this.operationMap = {}; + this.persistedDocMap = {}; + } + + /** + * Indexes definitions from a GraphQL document + * @param document The parsed GraphQL document + * @param filePath The file path for location tracking + */ + indexDocument(document: DocumentNode, filePath: string): void { + for (const def of document.definitions) { + this.indexDefinition(def, filePath); + } + } + + /** + * Scans @sdl(executables: [...]) to populate operationMap & persistedDocMap + * @param schemaSDL The schema SDL content to scan + * @param workspaceRoot The workspace root path for resolving relative paths + */ + scanSDLExecutables(schemaSDL: string, workspaceRoot: string): void { + // Validate inputs + if (!schemaSDL || typeof schemaSDL !== 'string') { + logger.error('Invalid schema SDL provided to scanSDLExecutables'); + return; + } + + if (!workspaceRoot || typeof workspaceRoot !== 'string') { + logger.error('Invalid workspace root provided to scanSDLExecutables'); + return; + } + + let ast; + try { + ast = parse(schemaSDL); + } catch (err) { + const error = new StepZenError( + "Error parsing schema SDL", + "SCHEMA_PARSE_ERROR", + err + ); + handleError(error); + return; + } + + const sdlDefs = ast.definitions.filter(def => + (def as DefinitionNode & { directives?: readonly { name: { value: string } }[] }) + .directives?.some(d => d.name.value === 'sdl') + ); + + sdlDefs.forEach((def: DefinitionNode & { directives?: readonly { name: { value: string }; arguments?: readonly any[] }[] }) => { + this.processSdlDefinition(def, workspaceRoot); + }); + } + + /** + * Computes a SHA256 hash of the input string. + * Used for generating stable document IDs. + * @param input The string to hash + * @returns Hex string of the SHA256 hash + */ + computeHash(input: string): string { + // Add validation + if (!input || typeof input !== 'string') { + logger.warn('Invalid input provided to computeHash'); + return ''; + } + + const { createHash } = require('crypto'); + return createHash('sha256').update(input).digest('hex'); + } + + // Getters + getDefinitionIndex(): Map { + return this.definitionIndex; + } + + getRootOperations(): Record { + return this.rootOperations; + } + + getFieldIndex(): Record { + return this.fieldIndex; + } + + getTypeDirectives(): Record { + return this.typeDirectives; + } + + getTypeRelationships(): TypeRelationship[] { + return this.typeRelationships; + } + + getOperationMap(): Record { + return this.operationMap; + } + + getPersistedDocMap(): Record { + return this.persistedDocMap; + } + + /** + * Finds all definitions of a symbol in the project. + * @param name The symbol name to find + * @returns Array of locations or undefined if not found + */ + findDefinition(name: string): SymbolLocation[] | undefined { + if (!name || typeof name !== 'string') { + logger.error(`Invalid symbol name provided: ${String(name)}`); + return undefined; + } + + logger.debug(`Searching for "${name}"...`); + return this.definitionIndex.get(name); + } + + private addLocation(key: string, loc: SymbolLocation): void { + const arr = this.definitionIndex.get(key); + if (arr) { + if (!arr.some(l => l.filePath === loc.filePath && l.line === loc.line && l.character === loc.character)) { + arr.push(loc); + } + } else { + this.definitionIndex.set(key, [loc]); + } + } + + private isRootObject(def: DefinitionNode): def is ObjectTypeDefinitionNode | ObjectTypeExtensionNode { + const isObjectType = def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.OBJECT_TYPE_EXTENSION; + if (!isObjectType) { + return false; + } + + const typeName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; + const isRoot = this.ROOT_TYPES.has(typeName as GraphQLOperationType); + + if (isRoot) { + logger.info(`Found root type: ${typeName}`); + } + + return isObjectType && isRoot; + } + + private indexDefinition(def: DefinitionNode, filePath: string): void { + // type‑level symbols + if ([ + Kind.OBJECT_TYPE_DEFINITION, + Kind.INPUT_OBJECT_TYPE_DEFINITION, + Kind.INTERFACE_TYPE_DEFINITION, + Kind.ENUM_TYPE_DEFINITION, + Kind.UNION_TYPE_DEFINITION, + Kind.SCALAR_TYPE_DEFINITION + ].includes(def.kind)) { + if (def.loc) { + this.addLocation((def as { name: { value: string } }).name.value, { + container: null, + filePath, + line: def.loc.startToken.line - 1, + character: def.loc.startToken.column - 1, + }); + } + } + + // root‑level fields + if (this.isRootObject(def) && (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields) { + const parentName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; + for (const field of (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields as FieldDefinitionNode[]) { + if (!field.loc) { + continue; + } + this.addLocation(field.name.value, { + container: parentName, + filePath, + line: field.loc.startToken.line - 1, + character: field.loc.startToken.column - 1, + }); + } + } + + // gather root operation info + if (this.isRootObject(def) && (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields) { + const parentTypeName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; + logger.info(`Processing fields for root type: ${parentTypeName}`); + + for (const field of (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields as FieldDefinitionNode[]) { + const fieldName = field.name.value; + const returnType = unwrapType(field.type); + const isList = isListType(field.type); + const args = field.arguments?.map(arg => ({ + name: arg.name.value, + type: getFullType(arg.type) + })) || []; + + logger.info(`Found root operation field: ${parentTypeName}.${fieldName} (returns: ${returnType})`); + + this.rootOperations[fieldName] = { + returnType, + isList, + args, + location: { + uri: filePath, + range: field.name.loc ? { + start: { line: field.name.loc.startToken.line, column: field.name.loc.startToken.column }, + end: { line: field.name.loc.endToken.line, column: field.name.loc.endToken.column } + } : null + } + }; + } + } + + // For ALL object types (not just root types) + if ((def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.OBJECT_TYPE_EXTENSION) && + (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields) { + const typeName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; + + // Store type directives if present + if (def.kind === Kind.OBJECT_TYPE_DEFINITION && (def as ObjectTypeDefinitionNode).directives?.length) { + this.typeDirectives[typeName] = (def as ObjectTypeDefinitionNode).directives?.map(d => ({ + name: d.name.value, + args: d.arguments?.map(arg => ({ + name: arg.name.value, + value: arg.value.kind === Kind.STRING ? (arg.value as StringValueNode).value : null + })) || [] + })) || []; + } + + // Store all fields + for (const field of (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields as FieldDefinitionNode[]) { + if (!field.loc) { + continue; + } + + // Record field type information for visualization + const returnType = unwrapType(field.type); + const isList = isListType(field.type); + const args = field.arguments?.map(arg => ({ + name: arg.name.value, + type: getFullType(arg.type) + })) || []; + + if (!this.fieldIndex[typeName]) { + this.fieldIndex[typeName] = []; + } + + this.fieldIndex[typeName].push({ + name: field.name.value, + type: returnType, + isList, + args, + directives: field.directives?.map(d => ({ + name: d.name.value, + args: d.arguments?.map(arg => ({ + name: arg.name.value, + value: arg.value.kind === Kind.STRING ? (arg.value as StringValueNode).value : null + })) || [] + })) || [], + location: { + uri: filePath, + line: field.loc.startToken.line - 1, + character: field.loc.startToken.column - 1 + } + }); + + // Store relationships between types (if returnType is a custom type, not scalar) + if (!isScalarType(returnType)) { + this.typeRelationships.push({ + fromType: typeName, + toType: returnType, + fieldName: field.name.value, + isList + }); + } + } + } + } + + private processSdlDefinition(def: DefinitionNode & { directives?: readonly { name: { value: string }; arguments?: readonly any[] }[] }, workspaceRoot: string): void { + if (!def.directives || !Array.isArray(def.directives)) { + return; + } + + const sdlDir = def.directives.find(d => d?.name?.value === 'sdl'); + if (!sdlDir || !sdlDir.arguments || !Array.isArray(sdlDir.arguments)) { + return; + } + + const execArg = sdlDir.arguments.find((a: ArgumentNode) => a?.name?.value === 'executables'); + if (!execArg || execArg.value?.kind !== Kind.LIST) { + return; + } + const values = (execArg.value as ListValueNode).values; + if (!values || !Array.isArray(values)) { + return; + } + + values.forEach(v => { + if (!v || v.kind !== Kind.OBJECT) { + return; + } + let documentPath = ''; + let persist = false; + + const fields = (v as ObjectValueNode).fields; + if (!fields || !Array.isArray(fields)) { + return; + } + + fields.forEach(field => { + if (!field.name || !field.value) { + return; + } + + if (field.name.value === 'document' && field.value.kind === Kind.STRING) { + documentPath = (field.value as StringValueNode).value; + } + if (field.name.value === 'persist' && field.value.kind === Kind.BOOLEAN) { + persist = (field.value as BooleanValueNode).value; + } + }); + + if (!documentPath) { + return; + } + + logger.debug(`Found executable: ${documentPath} (persist: ${persist})`); + + let abs; + try { + abs = path.isAbsolute(documentPath) + ? documentPath + : path.join(workspaceRoot, documentPath); + } catch (err) { + const error = new StepZenError( + `Error resolving path for ${documentPath}`, + "PATH_RESOLUTION_ERROR", + err + ); + handleError(error); + return; + } + + if (!abs || !existsSync(abs)) { + logger.warn(`File does not exist: ${abs}`); + return; + } + const fileUri = Uri.file(abs); + + let contents; + try { + contents = readFileSync(abs, 'utf8'); + if (!contents) { + logger.warn(`Empty file: ${abs}`); + return; + } + } catch (err) { + const error = new StepZenError( + `Error reading file ${abs}`, + "FILE_READ_ERROR", + err + ); + handleError(error); + return; + } + + let docAST; + try { + docAST = parse(contents); + } catch (err) { + const error = new StepZenError( + `Error parsing GraphQL in ${abs}`, + "GRAPHQL_PARSE_ERROR", + err + ); + handleError(error); + return; + } + + const ops: OperationEntry[] = []; + visit(docAST, { + OperationDefinition(node: OperationDefinitionNode) { + if (!node || !node.loc) { + return; + } + ops.push({ + name: node.name?.value || '', + type: node.operation, + fileUri, + range: { start: node.loc.start, end: node.loc.end }, + persisted: persist + }); + }, + FragmentDefinition(node: FragmentDefinitionNode) { + if (!node.loc) { + return; + } + ops.push({ + name: node.name.value, + type: 'fragment', + fileUri, + range: { start: node.loc.start, end: node.loc.end }, + persisted: persist + }); + } + }); + + this.operationMap[fileUri.toString()] = ops; + if (persist && ops && ops.length) { + const documentId = `sha256:${this.computeHash(contents)}`; + if (documentId) { + this.persistedDocMap[documentId] = { documentId, fileUri, operations: ops }; + } + } + }); + } +} \ No newline at end of file diff --git a/src/services/schema/linker.ts b/src/services/schema/linker.ts new file mode 100644 index 0000000..7fc3351 --- /dev/null +++ b/src/services/schema/linker.ts @@ -0,0 +1,67 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import { readFileSync, existsSync } from "fs"; +import * as path from "path"; +import { logger } from '../logger'; + +/** + * Traverses @sdl include directives in a loop-safe manner + * @param entryFile The starting file path + * @returns Array of all linked schema file paths + */ +export function traverseSDLIncludes(entryFile: string): string[] { + if (!entryFile || typeof entryFile !== 'string') { + logger.error('Invalid entry file path provided to traverseSDLIncludes'); + return []; + } + + if (!existsSync(entryFile)) { + logger.error(`File not found: ${entryFile}`); + return []; + } + + const visited = new Set(); + const schemaFiles: string[] = []; + const queue: string[] = [entryFile]; + + while (queue.length) { + const file = queue.pop()!; + + if (visited.has(file)) { + continue; + } + + visited.add(file); + schemaFiles.push(file); + + try { + const content = readFileSync(file, "utf8"); + + // Find @sdl(files: [...]) directives + const includeListRegex = /@sdl\(\s*files?\s*:\s*\[([^]+?)\]/g; + for (const inc of content.matchAll(includeListRegex)) { + const raw = inc[1]; + const pathRegex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; + + for (const m of raw.matchAll(pathRegex)) { + const rel = (m[1] ?? m[2]).trim(); + if (!rel) { + continue; + } + + const abs = path.join(path.dirname(file), rel); + if (!visited.has(abs) && existsSync(abs)) { + queue.push(abs); + } + } + } + } catch (err) { + logger.error(`Error reading file ${file}: ${err}`); + } + } + + return schemaFiles; +} \ No newline at end of file diff --git a/src/services/schema/parser.ts b/src/services/schema/parser.ts new file mode 100644 index 0000000..e46e5d4 --- /dev/null +++ b/src/services/schema/parser.ts @@ -0,0 +1,85 @@ +/** + * Copyright IBM Corp. 2025 + * Assisted by CursorAI + */ + +import { Kind, TypeNode } from "graphql"; +import { logger } from '../logger'; +import { GRAPHQL, GraphQLScalarType } from '../../utils/constants'; + +/** + * Peels away wrappers (NonNull, List) to get the named type. + * @param type The GraphQL type node to unwrap + * @returns The name of the inner named type + */ +export function unwrapType(type: TypeNode): string { + // Add validation + if (!type) { + logger.warn('Null type provided to unwrapType'); + return ''; + } + + if (type.kind === Kind.NAMED_TYPE) { + return type.name.value; + } + if (type.kind === Kind.NON_NULL_TYPE || type.kind === Kind.LIST_TYPE) { + return unwrapType(type.type); + } + return ''; +} + +/** + * Gets the full type string including nullability and list wrappers. + * @param type The GraphQL type node to analyze + * @returns The full type string with appropriate wrapping (e.g., "String!", "[Int]", "[User!]!") + */ +export function getFullType(type: TypeNode): string { + if (!type) { + logger.warn('Null type provided to getFullType'); + return ''; + } + + if (type.kind === Kind.NAMED_TYPE) { + return type.name.value; + } + + if (type.kind === Kind.LIST_TYPE) { + return `[${getFullType(type.type)}]`; + } + + if (type.kind === Kind.NON_NULL_TYPE) { + return `${getFullType(type.type)}!`; + } + + return ''; +} + +/** + * Checks if a type is or contains a list type. + * @param type The GraphQL type node to check + * @returns True if the type is or contains a list type + */ +export function isListType(type: TypeNode): boolean { + // Add validation + if (!type) { + logger.warn('Null type provided to isListType'); + return false; + } + + if (type.kind === Kind.LIST_TYPE) { + return true; + } + if (type.kind === Kind.NON_NULL_TYPE) { + return isListType(type.type); + } + return false; +} + +/** + * Checks if a type name represents a GraphQL scalar type. + * @param typeName The name of the type to check + * @returns True if the type is a scalar type + */ +export function isScalarType(typeName: string): boolean { + return GRAPHQL.SCALAR_TYPES.includes(typeName as GraphQLScalarType); +} \ No newline at end of file diff --git a/src/test/unit/scanner.test.ts b/src/test/unit/scanner.test.ts index 3752c6f..1fd0974 100644 --- a/src/test/unit/scanner.test.ts +++ b/src/test/unit/scanner.test.ts @@ -1,14 +1,7 @@ import * as assert from "assert"; import * as path from "path"; import * as vscode from "vscode"; -import { - scanStepZenProject, - getRootOperationsMap, - getFieldIndex, - getPersistedDocMap, - clearScannerState, - findDefinition, -} from "../../utils/stepzenProjectScanner"; +import { services } from "../../services"; import { createMock } from "../helpers/test-utils"; suite("StepZen Project Scanner Test Suite", () => { @@ -22,7 +15,7 @@ suite("StepZen Project Scanner Test Suite", () => { setup(() => { // Clear scanner state before each test - clearScannerState(); + services.schemaIndex.clearState(); // Create mock output channel mockOutputChannel = createMock({ @@ -84,10 +77,10 @@ suite("StepZen Project Scanner Test Suite", () => { const fixturePath = path.join(__dirname, "..", "..", "..", "src", "test", "fixtures", "schema-sample", "index.graphql"); // Scan the project - await scanStepZenProject(fixturePath); + await services.schemaIndex.scan(fixturePath); // Test 1: Check that we have root operations - const rootOperationsMap = getRootOperationsMap(); + const rootOperationsMap = services.schemaIndex.getRootOperations(); assert.ok( Object.keys(rootOperationsMap).length >= 2, `Expected at least 2 root operations, got ${Object.keys(rootOperationsMap).length}. Operations: ${Object.keys(rootOperationsMap).join(", ")}`, @@ -104,7 +97,7 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Test 3: Check that Query type has fields in field index - const fieldIndex = getFieldIndex(); + const fieldIndex = services.schemaIndex.getFieldIndex(); assert.ok( "Query" in fieldIndex, "Expected 'Query' type in field index", @@ -115,7 +108,7 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Test 4: Check that we have at least one persisted document - const persistedDocMap = getPersistedDocMap(); + const persistedDocMap = services.schemaIndex.getPersistedDocMap(); assert.ok( Object.keys(persistedDocMap).length > 0, `Expected at least one persisted document, got ${Object.keys(persistedDocMap).length}`, @@ -123,7 +116,7 @@ suite("StepZen Project Scanner Test Suite", () => { // Additional verification: Check that persisted documents contain operations const persistedDocs = Object.values(persistedDocMap); - const hasOperations = persistedDocs.some(doc => doc.operations && doc.operations.length > 0); + const hasOperations = persistedDocs.some(doc => doc && doc.operations && doc.operations.length > 0); assert.ok( hasOperations, "Expected at least one persisted document to contain operations", @@ -135,15 +128,15 @@ suite("StepZen Project Scanner Test Suite", () => { const fixturePath = path.join(__dirname, "..", "..", "..", "src", "test", "fixtures", "schema-sample", "index.graphql"); // Scan the project - await scanStepZenProject(fixturePath); + await services.schemaIndex.scan(fixturePath); // Check that we found the nested executable from products/index.graphql - const persistedDocMap = getPersistedDocMap(); + const persistedDocMap = services.schemaIndex.getPersistedDocMap(); const persistedDocs = Object.values(persistedDocMap); // Should have at least one persisted document from the nested SDL const hasPersistedFromNested = persistedDocs.some(doc => - doc.fileUri.toString().includes("product-queries.graphql") + doc && doc.fileUri.toString().includes("product-queries.graphql") ); assert.ok( @@ -157,9 +150,9 @@ suite("StepZen Project Scanner Test Suite", () => { const fixturePath = path.join(__dirname, "..", "..", "..", "src", "test", "fixtures", "schema-sample", "index.graphql"); // Scan the project - await scanStepZenProject(fixturePath); + await services.schemaIndex.scan(fixturePath); - const fieldIndex = getFieldIndex(); + const fieldIndex = services.schemaIndex.getFieldIndex(); // Should have various types from our fixture const expectedTypes = ["Query", "User", "Product", "Category"]; @@ -173,7 +166,7 @@ suite("StepZen Project Scanner Test Suite", () => { // Query should have the fields we defined const queryFields = fieldIndex["Query"]; - const queryFieldNames = queryFields.map(field => field.name); + const queryFieldNames = queryFields.map((field: any) => field.name); assert.ok( queryFieldNames.includes("hello"), @@ -192,12 +185,12 @@ suite("StepZen Project Scanner Test Suite", () => { setup(async () => { // Get the path to our test fixture and scan the project once for all go-to-definition tests fixturePath = path.join(__dirname, "..", "..", "..", "src", "test", "fixtures", "schema-sample", "index.graphql"); - await scanStepZenProject(fixturePath); + await services.schemaIndex.scan(fixturePath); }); test("should find type definitions for custom types", () => { // Test finding Product type definition (used in extension.graphql line 14: favoriteProducts: [Product]) - const productDefs = findDefinition("Product"); + const productDefs = services.schemaIndex.findDefinition("Product"); assert.ok(productDefs && productDefs.length > 0, "Should find Product type definition"); assert.ok( productDefs[0].filePath.includes("products/types/product.graphql"), @@ -206,7 +199,7 @@ suite("StepZen Project Scanner Test Suite", () => { assert.strictEqual(productDefs[0].container, null, "Product should be a type-level definition"); // Test finding Order type definition (used in extension.graphql line 5: recentOrders: [Order]) - const orderDefs = findDefinition("Order"); + const orderDefs = services.schemaIndex.findDefinition("Order"); assert.ok(orderDefs && orderDefs.length > 0, "Should find Order type definition"); assert.ok( orderDefs[0].filePath.includes("orders/atom.graphql"), @@ -214,7 +207,7 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Test finding User type definition - const userDefs = findDefinition("User"); + const userDefs = services.schemaIndex.findDefinition("User"); assert.ok(userDefs && userDefs.length > 0, "Should find User type definition"); assert.ok( userDefs[0].filePath.includes("users/schema.graphql"), @@ -224,7 +217,7 @@ suite("StepZen Project Scanner Test Suite", () => { test("should find enum definitions", () => { // Test finding ShippingCarrier enum (defined in extension.graphql) - const carrierDefs = findDefinition("ShippingCarrier"); + const carrierDefs = services.schemaIndex.findDefinition("ShippingCarrier"); assert.ok(carrierDefs && carrierDefs.length > 0, "Should find ShippingCarrier enum definition"); assert.ok( carrierDefs[0].filePath.includes("extension.graphql"), @@ -232,7 +225,7 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Test finding Currency enum (defined in products/types/product.graphql) - const currencyDefs = findDefinition("Currency"); + const currencyDefs = services.schemaIndex.findDefinition("Currency"); assert.ok(currencyDefs && currencyDefs.length > 0, "Should find Currency enum definition"); assert.ok( currencyDefs[0].filePath.includes("products/types/product.graphql"), @@ -240,7 +233,7 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Test finding OrderStatus enum (defined in orders/atom.graphql) - const statusDefs = findDefinition("OrderStatus"); + const statusDefs = services.schemaIndex.findDefinition("OrderStatus"); assert.ok(statusDefs && statusDefs.length > 0, "Should find OrderStatus enum definition"); assert.ok( statusDefs[0].filePath.includes("orders/atom.graphql"), @@ -250,7 +243,7 @@ suite("StepZen Project Scanner Test Suite", () => { test("should find interface definitions", () => { // Test finding Node interface (defined in products/types/product.graphql) - const nodeDefs = findDefinition("Node"); + const nodeDefs = services.schemaIndex.findDefinition("Node"); assert.ok(nodeDefs && nodeDefs.length > 0, "Should find Node interface definition"); assert.ok( nodeDefs[0].filePath.includes("products/types/product.graphql"), @@ -261,7 +254,7 @@ suite("StepZen Project Scanner Test Suite", () => { test("should find union definitions", () => { // Test finding SearchResult union (defined in products/types/product.graphql) - const searchDefs = findDefinition("SearchResult"); + const searchDefs = services.schemaIndex.findDefinition("SearchResult"); assert.ok(searchDefs && searchDefs.length > 0, "Should find SearchResult union definition"); assert.ok( searchDefs[0].filePath.includes("products/types/product.graphql"), @@ -271,7 +264,7 @@ suite("StepZen Project Scanner Test Suite", () => { test("should find scalar definitions", () => { // Test finding custom scalar UUID (defined in index.graphql) - const uuidDefs = findDefinition("UUID"); + const uuidDefs = services.schemaIndex.findDefinition("UUID"); assert.ok(uuidDefs && uuidDefs.length > 0, "Should find UUID scalar definition"); assert.ok( uuidDefs[0].filePath.includes("index.graphql"), @@ -279,7 +272,7 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Test finding EmailAddress scalar (defined in index.graphql) - const emailDefs = findDefinition("EmailAddress"); + const emailDefs = services.schemaIndex.findDefinition("EmailAddress"); assert.ok(emailDefs && emailDefs.length > 0, "Should find EmailAddress scalar definition"); assert.ok( emailDefs[0].filePath.includes("index.graphql"), @@ -289,31 +282,31 @@ suite("StepZen Project Scanner Test Suite", () => { test("should find field definitions within types (current behavior: root fields only)", () => { // Test finding Query fields (root operations) - this works correctly - const helloDefs = findDefinition("hello"); + const helloDefs = services.schemaIndex.findDefinition("hello"); assert.ok(helloDefs && helloDefs.length > 0, "Should find hello field definition"); - const helloInQuery = helloDefs.find(def => def.container === "Query"); + const helloInQuery = helloDefs.find((def: any) => def.container === "Query"); assert.ok(helloInQuery, "Should find hello field in Query type"); - const userDefs = findDefinition("user"); + const userDefs = services.schemaIndex.findDefinition("user"); assert.ok(userDefs && userDefs.length > 0, "Should find user field definition"); - const userInQuery = userDefs.find(def => def.container === "Query"); + const userInQuery = userDefs.find((def: any) => def.container === "Query"); assert.ok(userInQuery, "Should find user field in Query type"); // Current behavior: findDefinition only works for root operation fields, not regular type fields // This is the current scanner behavior that we're capturing with golden-path tests // TODO: In the future, findDefinition could be enhanced to also find fields within regular types - const recentOrdersDefs = findDefinition("recentOrders"); + const recentOrdersDefs = services.schemaIndex.findDefinition("recentOrders"); assert.strictEqual(recentOrdersDefs, undefined, "Current scanner behavior: findDefinition doesn't find non-root fields"); - const favoriteProductsDefs = findDefinition("favoriteProducts"); + const favoriteProductsDefs = services.schemaIndex.findDefinition("favoriteProducts"); assert.strictEqual(favoriteProductsDefs, undefined, "Current scanner behavior: findDefinition doesn't find non-root fields"); // However, these fields should be available in the field index - const fieldIndex = getFieldIndex(); + const fieldIndex = services.schemaIndex.getFieldIndex(); assert.ok(fieldIndex["User"], "User type should be in field index"); const userFields = fieldIndex["User"]; - const recentOrdersField = userFields.find(f => f.name === "recentOrders"); - const favoriteProductsField = userFields.find(f => f.name === "favoriteProducts"); + const recentOrdersField = userFields.find((f: any) => f.name === "recentOrders"); + const favoriteProductsField = userFields.find((f: any) => f.name === "favoriteProducts"); assert.ok(recentOrdersField, "recentOrders field should be in User field index"); assert.ok(favoriteProductsField, "favoriteProducts field should be in User field index"); @@ -325,33 +318,33 @@ suite("StepZen Project Scanner Test Suite", () => { test("should handle symbols that don't exist", () => { // Test searching for non-existent symbols - const nonExistentDefs = findDefinition("NonExistentType"); + const nonExistentDefs = services.schemaIndex.findDefinition("NonExistentType"); assert.strictEqual(nonExistentDefs, undefined, "Should return undefined for non-existent symbols"); - const anotherNonExistentDefs = findDefinition("FakeField"); + const anotherNonExistentDefs = services.schemaIndex.findDefinition("FakeField"); assert.strictEqual(anotherNonExistentDefs, undefined, "Should return undefined for non-existent fields"); }); test("should handle invalid input gracefully", () => { // Test with empty string - const emptyDefs = findDefinition(""); + const emptyDefs = services.schemaIndex.findDefinition(""); assert.strictEqual(emptyDefs, undefined, "Should return undefined for empty string"); // Test with null/undefined (TypeScript should prevent this, but test runtime behavior) - const nullDefs = findDefinition(null as any); + const nullDefs = services.schemaIndex.findDefinition(null as any); assert.strictEqual(nullDefs, undefined, "Should return undefined for null input"); - const undefinedDefs = findDefinition(undefined as any); + const undefinedDefs = services.schemaIndex.findDefinition(undefined as any); assert.strictEqual(undefinedDefs, undefined, "Should return undefined for undefined input"); }); test("should find multiple definitions when symbols are extended", () => { // User type is defined in users/schema.graphql and extended in extension.graphql - const userTypeDefs = findDefinition("User"); + const userTypeDefs = services.schemaIndex.findDefinition("User"); assert.ok(userTypeDefs && userTypeDefs.length > 0, "Should find User type definitions"); // Should find the main User type definition - const mainUserDef = userTypeDefs.find(def => def.container === null); + const mainUserDef = userTypeDefs.find((def: any) => def.container === null); assert.ok(mainUserDef, "Should find main User type definition"); assert.ok( mainUserDef.filePath.includes("users/schema.graphql"), @@ -359,11 +352,11 @@ suite("StepZen Project Scanner Test Suite", () => { ); // Product type is defined in products/types/product.graphql and extended in extension.graphql - const productTypeDefs = findDefinition("Product"); + const productTypeDefs = services.schemaIndex.findDefinition("Product"); assert.ok(productTypeDefs && productTypeDefs.length > 0, "Should find Product type definitions"); // Should find the main Product type definition - const mainProductDef = productTypeDefs.find(def => def.container === null); + const mainProductDef = productTypeDefs.find((def: any) => def.container === null); assert.ok(mainProductDef, "Should find main Product type definition"); assert.ok( mainProductDef.filePath.includes("products/types/product.graphql"), diff --git a/src/test/unit/services/service-registry.test.ts b/src/test/unit/services/service-registry.test.ts index fef3829..c56b2cf 100644 --- a/src/test/unit/services/service-registry.test.ts +++ b/src/test/unit/services/service-registry.test.ts @@ -4,6 +4,7 @@ import { createMock } from '../../helpers/test-utils'; import { StepzenCliService } from '../../../services/cli'; import { Logger } from '../../../services/logger'; import { ProjectResolver } from '../../../services/projectResolver'; +import { SchemaIndexService } from '../../../services/SchemaIndexService'; suite('Service Registry', () => { let originalServices: ServiceRegistry; @@ -23,10 +24,11 @@ suite('Service Registry', () => { setMockServices(originalServices); }); - test('services should contain cli, logger, and projectResolver by default', () => { + test('services should contain cli, logger, projectResolver, and schemaIndex by default', () => { assert.ok(services.cli instanceof StepzenCliService, 'CLI service should be an instance of StepzenCliService'); assert.ok(services.logger instanceof Logger, 'Logger should be an instance of Logger'); assert.ok(services.projectResolver instanceof ProjectResolver, 'ProjectResolver should be an instance of ProjectResolver'); + assert.ok(services.schemaIndex instanceof SchemaIndexService, 'SchemaIndex service should be an instance of SchemaIndexService'); }); test('overrideServices should replace individual services', () => { @@ -76,6 +78,18 @@ suite('Service Registry', () => { resolveStepZenProjectRoot: async () => '/mock/project/root', clearCache: () => { /* mock implementation */ }, getCachedProjectRoot: () => '/mock/cached/root' + }), + schemaIndex: createMock({ + scan: async () => { /* mock implementation */ }, + clearState: () => { /* mock implementation */ }, + findDefinition: () => undefined, + getRootOperations: () => ({}), + getOperationMap: () => ({}), + getPersistedDocMap: () => ({}), + getFieldIndex: () => ({}), + getTypeDirectives: () => ({}), + getTypeRelationships: () => [], + computeHash: () => 'mock-hash' }) }; @@ -86,11 +100,13 @@ suite('Service Registry', () => { assert.strictEqual(services.cli, mockServices.cli, 'CLI service should be replaced with mock'); assert.strictEqual(services.logger, mockServices.logger, 'Logger service should be replaced with mock'); assert.strictEqual(services.projectResolver, mockServices.projectResolver, 'ProjectResolver service should be replaced with mock'); + assert.strictEqual(services.schemaIndex, mockServices.schemaIndex, 'SchemaIndex service should be replaced with mock'); // Verify that previous contains all original services assert.strictEqual(previous.cli, originalServices.cli, 'previous should contain original CLI service'); assert.strictEqual(previous.logger, originalServices.logger, 'previous should contain original logger service'); assert.strictEqual(previous.projectResolver, originalServices.projectResolver, 'previous should contain original ProjectResolver service'); + assert.strictEqual(previous.schemaIndex, originalServices.schemaIndex, 'previous should contain original SchemaIndex service'); // Reset to original services setMockServices(previous); @@ -99,6 +115,7 @@ suite('Service Registry', () => { assert.strictEqual(services.cli, originalServices.cli, 'CLI service should be restored'); assert.strictEqual(services.logger, originalServices.logger, 'Logger service should be restored'); assert.strictEqual(services.projectResolver, originalServices.projectResolver, 'ProjectResolver service should be restored'); + assert.strictEqual(services.schemaIndex, originalServices.schemaIndex, 'SchemaIndex service should be restored'); }); test('mocked service should be usable in place of real service', async () => { diff --git a/src/utils/codelensProvider.ts b/src/utils/codelensProvider.ts index 0d5fb44..3fa3d0c 100644 --- a/src/utils/codelensProvider.ts +++ b/src/utils/codelensProvider.ts @@ -4,11 +4,8 @@ */ import * as vscode from "vscode"; -import { - getOperationMap, - getPersistedDocMap, - OperationEntry, -} from "./stepzenProjectScanner"; +import type { OperationEntry } from "../services/schema/indexer"; +import { services } from "../services"; import { COMMANDS } from "./constants"; /** @@ -29,8 +26,8 @@ export class StepZenCodeLensProvider implements vscode.CodeLensProvider { document: vscode.TextDocument, ): vscode.CodeLens[] { const lenses: vscode.CodeLens[] = []; - const opMap = getOperationMap(); - const persistedMap = getPersistedDocMap(); + const opMap = services.schemaIndex.getOperationMap(); + const persistedMap = services.schemaIndex.getPersistedDocMap(); const uriKey = document.uri.toString(); const ops = opMap[uriKey] || []; @@ -52,7 +49,7 @@ export class StepZenCodeLensProvider implements vscode.CodeLensProvider { // find the matching persisted document entry const entry = Object.values(persistedMap).find( (e) => - e.fileUri.toString() === uriKey && + e && e.fileUri.toString() === uriKey && e.operations.some((o) => o.name === op.name), ); if (entry) { diff --git a/src/utils/runtimeDiagnostics.ts b/src/utils/runtimeDiagnostics.ts index 9416234..9501872 100644 --- a/src/utils/runtimeDiagnostics.ts +++ b/src/utils/runtimeDiagnostics.ts @@ -6,7 +6,7 @@ * Implements ancestor-walk to accurately match nested fetch spans. * ---------------------------------------------------------*/ import * as vscode from "vscode"; -import { findDefinition } from "../utils/stepzenProjectScanner"; +// Removed import - now using services.schemaIndex directly import { services } from "../services"; import { StepZenDiagnostic } from "../types"; @@ -176,14 +176,14 @@ function locateField(pathKey: string): { uri: vscode.Uri; range: vscode.Range } } const parts = pathKey.split('.'); if (parts.length === 2) { - const locs = findDefinition(parts[1]) || []; + const locs = services.schemaIndex.findDefinition(parts[1]) || []; const match = locs.find((l) => l.container === parts[0]); if (match) { return toVsLoc(match); } return undefined; } else if (parts.length === 1) { - const locs = findDefinition(parts[0]) || []; + const locs = services.schemaIndex.findDefinition(parts[0]) || []; const match = locs.find((l) => ['Query','Mutation','Subscription'].includes(l.container || '')); if (match) { return toVsLoc(match); diff --git a/src/utils/stepzenProjectScanner.ts b/src/utils/stepzenProjectScanner.ts deleted file mode 100644 index 4a29983..0000000 --- a/src/utils/stepzenProjectScanner.ts +++ /dev/null @@ -1,742 +0,0 @@ -/** - * Copyright IBM Corp. 2025 - * Assisted by CursorAI - */ - -import { readFileSync, existsSync } from "fs"; -import * as path from "path"; -import { logger } from '../services/logger'; -import { GRAPHQL, GraphQLOperationType, GraphQLScalarType } from './constants'; -import { - parse, - visit, - Kind, - DocumentNode, - DefinitionNode, - ObjectTypeDefinitionNode, - ObjectTypeExtensionNode, - FieldDefinitionNode, - TypeNode, - OperationDefinitionNode, - FragmentDefinitionNode, - ListValueNode, - ObjectValueNode, - StringValueNode, - BooleanValueNode, - ArgumentNode -} from "graphql"; -import * as vscode from 'vscode'; -import { Uri } from 'vscode'; -import { StepZenError, handleError } from "../errors"; - -/** Location of a symbol inside a file (0‑based). */ -interface Location { - filePath: string; - line: number; - character: number; -} - -/** Container‑aware location (null container ⇒ type‑level symbol). */ -interface SymbolLocation extends Location { - container: string | null; -} - -export interface ArgInfo { - name: string; - type: string; -} - -interface RootOperationInfo { - returnType: string; - isList: boolean; - args: ArgInfo[]; - location: { - uri: string; - range: { - start: { line: number; column: number; }; - end: { line: number; column: number; }; - } | null - }; -} - -// Global maps -const rootOperations: Record = {}; -let definitionIndex: Map = new Map(); -const visited = new Set(); -const schemaFiles: string[] = []; -const operationMap: Record = {}; -const persistedDocMap: Record = {}; -const fieldIndex: Record = {}; // Parent type -> fields -const typeDirectives: Record = {}; // Type -> directives -const typeRelationships: TypeRelationship[] = []; // Relationships between types - -export interface OperationEntry { - name: string; - type: 'query' | 'mutation' | 'subscription' | 'fragment'; - fileUri: Uri; - range: { start: number; end: number }; - persisted: boolean; -} - -interface PersistedDocEntry { - documentId: string; - fileUri: Uri; - operations: OperationEntry[]; -} - -export interface DirectiveInfo { - name: string; - args: { name: string; value: string | null }[]; -} - -export interface FieldInfo { - name: string; - type: string; - isList: boolean; - args: ArgInfo[]; - directives: DirectiveInfo[]; - location: { uri: string; line: number; character: number }; -} - -export interface TypeRelationship { - fromType: string; - toType: string; - fieldName: string; - isList: boolean; -} - -/** - * Clears the definition index and resets all data structures. - * Used when rescanning a project to avoid stale data. - */ -function clearDefinitionIndex() { - definitionIndex = new Map(); - visited.clear(); - schemaFiles.length = 0; - - // Clear the new maps as well - Object.keys(fieldIndex).forEach(key => delete fieldIndex[key]); - Object.keys(typeDirectives).forEach(key => delete typeDirectives[key]); - typeRelationships.length = 0; -} - -/** - * Clears all scanner state including root operations, operation map, and persisted documents. - * Used for testing to ensure clean state between test runs. - */ -export function clearScannerState() { - clearDefinitionIndex(); - - // Clear root operations - Object.keys(rootOperations).forEach(key => delete rootOperations[key]); - - // Clear operation map - Object.keys(operationMap).forEach(key => delete operationMap[key]); - - // Clear persisted document map - Object.keys(persistedDocMap).forEach(key => delete persistedDocMap[key]); -} - -/** - * Finds all definitions of a symbol in the project. - * @param name The symbol name to find - * @returns Array of locations or undefined if not found - */ -export function findDefinition(name: string): SymbolLocation[] | undefined { - if (!name || typeof name !== 'string') { - logger.error(`Invalid symbol name provided: ${String(name)}`); - return undefined; - } - - logger.debug(`Searching for "${name}"...`); - return definitionIndex.get(name); -} - -// TODO: CLEANUP -// /** -// * Dumps the entire definition index for debugging purposes. -// * @returns JSON string representation of the definition index -// */ -// function dumpDefinitionIndex(): string { -// const obj: Record = {}; -// for (const [k, v] of definitionIndex.entries()) { -// obj[k] = v; -// } -// return JSON.stringify(obj, null, 2); -// } - -/* ───────────────────────── helpers ───────────────────────── */ -const ROOT_TYPES = new Set(GRAPHQL.ROOT_OPERATION_TYPES); - -function addLocation(key: string, loc: SymbolLocation) { - const arr = definitionIndex.get(key); - if (arr) { - if (!arr.some(l => l.filePath === loc.filePath && l.line === loc.line && l.character === loc.character)) { - arr.push(loc); - } - } else { - definitionIndex.set(key, [loc]); - } -} - -function isRootObject(def: DefinitionNode): def is ObjectTypeDefinitionNode | ObjectTypeExtensionNode { - const isObjectType = def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.OBJECT_TYPE_EXTENSION; - if (!isObjectType) { - return false; - } - - const typeName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; - const isRoot = ROOT_TYPES.has(typeName as GraphQLOperationType); - - if (isRoot) { - logger.info(`Found root type: ${typeName}`); - } - - return isObjectType && isRoot; -} - -function indexDefinitions(document: DocumentNode, filePath: string) { - for (const def of document.definitions) { - // type‑level symbols - if ([ - Kind.OBJECT_TYPE_DEFINITION, - Kind.INPUT_OBJECT_TYPE_DEFINITION, - Kind.INTERFACE_TYPE_DEFINITION, - Kind.ENUM_TYPE_DEFINITION, - Kind.UNION_TYPE_DEFINITION, - Kind.SCALAR_TYPE_DEFINITION - ].includes(def.kind)) { - if (def.loc) { - addLocation((def as { name: { value: string } }).name.value, { - container: null, - filePath, - line: def.loc.startToken.line - 1, - character: def.loc.startToken.column - 1, - }); - } - } - // root‑level fields - if (isRootObject(def) && (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields) { - const parentName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; - for (const field of (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields as FieldDefinitionNode[]) { - if (!field.loc) { - continue; - } - addLocation(field.name.value, { - container: parentName, - filePath, - line: field.loc.startToken.line - 1, - character: field.loc.startToken.column - 1, - }); - } - } - // gather root operation info - if (isRootObject(def) && (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields) { - const parentTypeName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; - logger.info(`Processing fields for root type: ${parentTypeName}`); - - for (const field of (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields as FieldDefinitionNode[]) { - const fieldName = field.name.value; - const returnType = unwrapType(field.type); - const isList = isListType(field.type); - const args = field.arguments?.map(arg => ({ - name: arg.name.value, - type: getFullType(arg.type) - })) || []; - - logger.info(`Found root operation field: ${parentTypeName}.${fieldName} (returns: ${returnType})`); - - rootOperations[fieldName] = { - returnType, - isList, - args, - location: { - uri: filePath, - range: field.name.loc ? { - start: { line: field.name.loc.startToken.line, column: field.name.loc.startToken.column }, - end: { line: field.name.loc.endToken.line, column: field.name.loc.endToken.column } - } : null - } - }; - } - } - - // For ALL object types (not just root types) - if ((def.kind === Kind.OBJECT_TYPE_DEFINITION || def.kind === Kind.OBJECT_TYPE_EXTENSION) && - (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields) { - const typeName = (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).name.value; - - // Store type directives if present - if (def.kind === Kind.OBJECT_TYPE_DEFINITION && (def as ObjectTypeDefinitionNode).directives?.length) { - typeDirectives[typeName] = (def as ObjectTypeDefinitionNode).directives?.map(d => ({ - name: d.name.value, - args: d.arguments?.map(arg => ({ - name: arg.name.value, - value: arg.value.kind === Kind.STRING ? (arg.value as StringValueNode).value : null - })) || [] - })) || []; - } - - // Store all fields - for (const field of (def as ObjectTypeDefinitionNode | ObjectTypeExtensionNode).fields as FieldDefinitionNode[]) { - if (!field.loc) { - continue; - } - - // Record field type information for visualization - const returnType = unwrapType(field.type); - const isList = isListType(field.type); - const args = field.arguments?.map(arg => ({ - name: arg.name.value, - type: getFullType(arg.type) - })) || []; - - if (!fieldIndex[typeName]) { - fieldIndex[typeName] = []; - } - - fieldIndex[typeName].push({ - name: field.name.value, - type: returnType, - isList, - args, - directives: field.directives?.map(d => ({ - name: d.name.value, - args: d.arguments?.map(arg => ({ - name: arg.name.value, - value: arg.value.kind === Kind.STRING ? (arg.value as StringValueNode).value : null - })) || [] - })) || [], - location: { - uri: filePath, - line: field.loc.startToken.line - 1, - character: field.loc.startToken.column - 1 - } - }); - - // Store relationships between types (if returnType is a custom type, not scalar) - if (!isScalarType(returnType)) { - typeRelationships.push({ - fromType: typeName, - toType: returnType, - fieldName: field.name.value, - isList - }); - } - } - } - } -} - -/** - * Scans @sdl(executables: [...]) to populate operationMap & persistedDocMap - * @param schemaSDL The schema SDL content to scan - * @param workspaceRoot The workspace root path for resolving relative paths - */ -function scanSDLExecutables(schemaSDL: string, workspaceRoot: string) { - // Validate inputs - if (!schemaSDL || typeof schemaSDL !== 'string') { - logger.error('Invalid schema SDL provided to scanSDLExecutables'); - return; - } - - if (!workspaceRoot || typeof workspaceRoot !== 'string') { - logger.error('Invalid workspace root provided to scanSDLExecutables'); - return; - } - - let ast; - try { - ast = parse(schemaSDL); - } catch (err) { - const error = new StepZenError( - "Error parsing schema SDL", - "SCHEMA_PARSE_ERROR", - err - ); - handleError(error); - return; - } - - const sdlDefs = ast.definitions.filter(def => - (def as DefinitionNode & { directives?: readonly { name: { value: string } }[] }) - .directives?.some(d => d.name.value === 'sdl') - ); - sdlDefs.forEach((def: DefinitionNode & { directives?: readonly { name: { value: string }; arguments?: readonly any[] }[] }) => { - if (!def.directives || !Array.isArray(def.directives)) { - return; - } - - const sdlDir = def.directives.find(d => d?.name?.value === 'sdl'); - if (!sdlDir || !sdlDir.arguments || !Array.isArray(sdlDir.arguments)) { - return; - } - - const execArg = sdlDir.arguments.find((a: ArgumentNode) => a?.name?.value === 'executables'); - if (!execArg || execArg.value?.kind !== Kind.LIST) { - return; - } - const values = (execArg.value as ListValueNode).values; - if (!values || !Array.isArray(values)) { - return; - } - - values.forEach(v => { - if (!v || v.kind !== Kind.OBJECT) { - return; - } - let documentPath = ''; - let persist = false; - - const fields = (v as ObjectValueNode).fields; - if (!fields || !Array.isArray(fields)) { - return; - } - - fields.forEach(field => { - if (!field.name || !field.value) { - return; - } - - if (field.name.value === 'document' && field.value.kind === Kind.STRING) { - documentPath = (field.value as StringValueNode).value; - } - if (field.name.value === 'persist' && field.value.kind === Kind.BOOLEAN) { - persist = (field.value as BooleanValueNode).value; - } - }); - - if (!documentPath) { - return; - } - // log - logger.debug(`Found executable: ${documentPath} (persist: ${persist})`); - - let abs; - try { - abs = path.isAbsolute(documentPath) - ? documentPath - : path.join(workspaceRoot, documentPath); - } catch (err) { - const error = new StepZenError( - `Error resolving path for ${documentPath}`, - "PATH_RESOLUTION_ERROR", - err - ); - handleError(error); - return; - } - - if (!abs || !existsSync(abs)) { - logger.warn(`File does not exist: ${abs}`); - return; - } - const fileUri = Uri.file(abs); - - let contents; - try { - contents = readFileSync(abs, 'utf8'); - if (!contents) { - logger.warn(`Empty file: ${abs}`); - return; - } - } catch (err) { - const error = new StepZenError( - `Error reading file ${abs}`, - "FILE_READ_ERROR", - err - ); - handleError(error); - return; - } - - let docAST; - try { - docAST = parse(contents); - } catch (err) { - const error = new StepZenError( - `Error parsing GraphQL in ${abs}`, - "GRAPHQL_PARSE_ERROR", - err - ); - handleError(error); - return; - } - const ops: OperationEntry[] = []; - visit(docAST, { - OperationDefinition(node: OperationDefinitionNode) { - if (!node || !node.loc) { - return; - } - ops.push({ - name: node.name?.value || '', - type: node.operation, - fileUri, - range: { start: node.loc.start, end: node.loc.end }, - persisted: persist - }); - }, - FragmentDefinition(node: FragmentDefinitionNode) { - if (!node.loc) { - return; - } - ops.push({ - name: node.name.value, - type: 'fragment', - fileUri, - range: { start: node.loc.start, end: node.loc.end }, - persisted: persist - }); - } - }); - operationMap[fileUri.toString()] = ops; - if (persist && ops && ops.length) { - const documentId = `sha256:${computeHash(contents)}`; - if (documentId) { - persistedDocMap[documentId] = { documentId, fileUri, operations: ops }; - } - } - }); - }); -} - -/** - * Project scan: gather definitions and build schema & operations maps - */ -/** - * Scans a StepZen project starting from an entry file. - * Traverses all linked schema files, builds definitions index, - * and populates operation and type information. - * - * @param entryFile The path to the main entry file (typically index.graphql) - * @returns Promise that resolves when scanning completes - */ -export async function scanStepZenProject(entryFile: string): Promise { - // Add validation - if (!entryFile || typeof entryFile !== 'string') { - logger.error('Invalid entry file path provided'); - return; - } - - logger.info(`Scanning project starting at ${entryFile}`); - logger.info(`ROOT_TYPES: ${Array.from(ROOT_TYPES).join(', ')}`); - - if (!existsSync(entryFile)) { - logger.error(`File not found: ${entryFile}`); - return; - } - - - // Show progress notification to the user - vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: "StepZen: Scanning schema...", - cancellable: false - }, async (progress) => { - const queue: string[] = [entryFile]; - let filesProcessed = 0; - - clearDefinitionIndex(); - progress.report({ message: "Starting scan...", increment: 0 }); - - while (queue.length) { - const file = queue.pop()!; - logger.debug(`Parsing ${file}`); - if (visited.has(file)) { - continue; - } - visited.add(file); - schemaFiles.push(file); - - const content = readFileSync(file, "utf8"); - const doc = parse(content, { noLocation: false }); - indexDefinitions(doc, file); - - filesProcessed++; - progress.report({ - message: `Processed ${filesProcessed} files...`, - increment: 5 - }); - - const includeListRegex = /@sdl\(\s*files?\s*:\s*\[([^]+?)\]/g; - for (const inc of content.matchAll(includeListRegex)) { - const raw = inc[1]; - const pathRegex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'/g; - for (const m of raw.matchAll(pathRegex)) { - const rel = (m[1] ?? m[2]).trim(); - if (!rel) { - continue; - } - const abs = path.join(path.dirname(file), rel); - if (!visited.has(abs)) { - queue.push(abs); - } - } - } - } - - progress.report({ message: "Building schema...", increment: 20 }); - - // After collecting all schema files, build the full SDL and scan executables - const fullSDL = schemaFiles.map(fp => readFileSync(fp, 'utf8')).join('\n'); - scanSDLExecutables(fullSDL, path.dirname(entryFile)); - - - progress.report({ message: "Schema scan complete", increment: 100 }); - - // Log detailed information about what was found - logger.info(`Root operations found: ${Object.keys(rootOperations).length}`); - for (const [fieldName, fieldInfo] of Object.entries(rootOperations)) { - logger.info(` - ${fieldName} (returns: ${fieldInfo.returnType}, list: ${fieldInfo.isList})`); - } - - logger.info(`Types in field index: ${Object.keys(fieldIndex).length}`); - for (const typeName of Object.keys(fieldIndex)) { - logger.info(` ${typeName}: ${fieldIndex[typeName].length} fields`); - } - - logger.info(`Schema scan completed: ${filesProcessed} files processed, ${Object.keys(fieldIndex).length} types found`); - }); -} - -/** - * Returns the map of root operations (fields on Query, Mutation, Subscription). - * @returns Record of operation names to operation details - */ -export function getRootOperationsMap() { - return rootOperations; -} - -/** - * Returns the map of GraphQL operations in all scanned files. - * @returns Map of file URIs to operation entries - */ -export function getOperationMap() { - return operationMap; -} - -/** - * Returns the map of persisted documents. - * @returns Map of document IDs to persisted document entries - */ -export function getPersistedDocMap() { - return persistedDocMap; -} - -/** - * Returns the index of fields grouped by parent type. - * @returns Record mapping type names to their field information - */ -export function getFieldIndex() { - return fieldIndex; -} - -/** - * Returns directives applied to each type. - * @returns Record mapping type names to their directive information - */ -export function getTypeDirectives() { - return typeDirectives; -} - -/** - * Returns relationships between types in the schema. - * @returns Array of type relationship objects - */ -export function getTypeRelationships() { - return typeRelationships; -} - -/** - * Peels away wrappers (NonNull, List) to get the named type. - * @param type The GraphQL type node to unwrap - * @returns The name of the inner named type - */ -function unwrapType(type: TypeNode): string { - // Add validation - if (!type) { - logger.warn('Null type provided to unwrapType'); - return ''; - } - - if (type.kind === Kind.NAMED_TYPE) { - return type.name.value; - } - if (type.kind === Kind.NON_NULL_TYPE || type.kind === Kind.LIST_TYPE) { - return unwrapType(type.type); - } - return ''; -} - -/** - * Gets the full type string including nullability and list wrappers. - * @param type The GraphQL type node to analyze - * @returns The full type string with appropriate wrapping (e.g., "String!", "[Int]", "[User!]!") - */ -function getFullType(type: TypeNode): string { - if (!type) { - logger.warn('Null type provided to getFullType'); - return ''; - } - - if (type.kind === Kind.NAMED_TYPE) { - return type.name.value; - } - - if (type.kind === Kind.LIST_TYPE) { - return `[${getFullType(type.type)}]`; - } - - if (type.kind === Kind.NON_NULL_TYPE) { - return `${getFullType(type.type)}!`; - } - - return ''; -} - -/** - * Checks if a type is or contains a list type. - * @param type The GraphQL type node to check - * @returns True if the type is or contains a list type - */ -function isListType(type: TypeNode): boolean { - // Add validation - if (!type) { - logger.warn('Null type provided to isListType'); - return false; - } - - if (type.kind === Kind.LIST_TYPE) { - return true; - } - if (type.kind === Kind.NON_NULL_TYPE) { - return isListType(type.type); - } - return false; -} - -/** - * Computes a SHA256 hash of the input string. - * Used for generating stable document IDs. - * @param input The string to hash - * @returns Hex string of the SHA256 hash - */ -export function computeHash(input: string): string { - // Add validation - if (!input || typeof input !== 'string') { - logger.warn('Invalid input provided to computeHash'); - return ''; - } - - const { createHash } = require('crypto'); - return createHash('sha256').update(input).digest('hex'); -} - -/** - * Checks if a type name represents a GraphQL scalar type. - * @param typeName The name of the type to check - * @returns True if the type is a scalar type - */ -function isScalarType(typeName: string): boolean { - return GRAPHQL.SCALAR_TYPES.includes(typeName as GraphQLScalarType); -}