import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { Kind, parse, validate, type DocumentNode, type FragmentDefinitionNode } from 'graphql'; import { extractBlocks } from './extract'; import { safeListFilesRecursive } from './fs-util'; import { loadFragmentDefinitionsFromDirs, resolveFragments } from './fragments'; import { loadSchemaGroups } from './schema'; import type { SchemaDriftConfig } from './types'; export interface DriftError { file: string; index: number; operationName: string | null; messages: string[]; } export interface DriftReport { errors: DriftError[]; /** Count of operation documents actually validated (fragment-only blocks are not counted). */ documentCount: number; fileCount: number; } function getOperationName(doc: DocumentNode): string | null { for (const def of doc.definitions) { if (def.kind === Kind.OPERATION_DEFINITION || def.kind === Kind.FRAGMENT_DEFINITION) { return def.name?.value ?? null; } } return null; } /** * Run the configured extraction + validation pipeline against a repo. * * `repoRoot` is the directory `schema-drift.config.json` lives in — all * paths in the config (`sourceRoots[].dir`, `fragmentDirs`, * `schemaGroups[].files`) are resolved relative to it. */ export function runValidation(config: SchemaDriftConfig, repoRoot: string): DriftReport { const schemaGroups = loadSchemaGroups(config.schemaGroups, repoRoot); const treatUnresolvedAsError = config.treatUnresolvedInterpolationAsError ?? true; // Pass 1: scan every configured source file once, extracting both operation // blocks and inline `fragment` blocks. Inline fragments are pooled globally // (a fragment defined in one file is commonly spread from a hook/component // in another within the same MFE). const sourceFiles: string[] = []; for (const root of config.sourceRoots) { const extensions = root.extensions ?? ['.ts', '.tsx']; sourceFiles.push(...safeListFilesRecursive(join(repoRoot, root.dir), extensions)); } const inlineFragmentPool = new Map(); const pendingOperations: Array<{ relFile: string; index: number; source: string; unresolved: string[]; }> = []; for (const file of sourceFiles) { const relFile = file.slice(repoRoot.length + 1); const text = readFileSync(file, 'utf8'); for (const block of extractBlocks(text, relFile)) { if (block.isFragment) { if (block.unresolved.length > 0) continue; // can't parse a fragment with unresolved interpolation; surfaces via any spread that needs it try { const fragDoc = parse(block.source); for (const def of fragDoc.definitions) { if (def.kind === Kind.FRAGMENT_DEFINITION) inlineFragmentPool.set(def.name.value, def); } } catch { // Malformed inline fragment: fall through silently here — if it's // actually spread anywhere, the consuming operation will fail to // resolve the fragment name and be reported as an error there. } continue; } pendingOperations.push({ relFile, index: block.index, source: block.source, unresolved: block.unresolved, }); } } const fileFragmentPool = config.fragmentDirs ? loadFragmentDefinitionsFromDirs(config.fragmentDirs, repoRoot) : new Map(); const fragmentPool = new Map([ ...fileFragmentPool, ...inlineFragmentPool, ]); // Pass 2: parse + resolve fragments + validate every operation block against // every schema group, in order. A document is clean if ANY group accepts it. const errors: DriftError[] = []; let documentCount = 0; for (const op of pendingOperations) { documentCount++; if (op.unresolved.length > 0 && treatUnresolvedAsError) { errors.push({ file: op.relFile, index: op.index, operationName: null, messages: [ `Unresolved interpolation(s): ${[...new Set(op.unresolved)].join(', ')}. Define them as ` + `top-level backtick string constants in the same file so this operation can be validated, ` + `or set "treatUnresolvedInterpolationAsError": false in schema-drift.config.json if this is ` + `verified to be validation-irrelevant.`, ], }); continue; } let doc: DocumentNode; try { doc = parse(op.source); } catch (e) { errors.push({ file: op.relFile, index: op.index, operationName: null, messages: [`Parse error: ${(e as Error).message}`], }); continue; } const resolvedDoc = resolveFragments(doc, fragmentPool); let bestErrors: string[] | null = null; let passed = false; for (const group of schemaGroups) { const groupErrors = validate(group.schema, resolvedDoc); if (groupErrors.length === 0) { passed = true; break; } // Report the FIRST group's errors as the actionable ones if nothing passes — // it's the group most likely to be this document's "home" schema. if (bestErrors === null) bestErrors = groupErrors.map((e) => e.message); } if (!passed) { errors.push({ file: op.relFile, index: op.index, operationName: getOperationName(doc), messages: bestErrors ?? ['No schema group accepted this document.'], }); } } return { errors, documentCount, fileCount: sourceFiles.length }; }