UNPKG

graphql-lint-clint-platform

Version:

GraphQL unused fields linter for Clint platform - Custom patterns and actions.graphql support

345 lines (284 loc) 12.6 kB
import { GraphQLQuery, GraphQLField } from "graphql-lint-unused-fields"; import fs from "fs"; import path from "path"; import { glob } from "glob"; interface ClintAction { name: string; pattern: string; // clint.entity.method file: string; line: number; } export class ClintGraphQLExtractor { private actionPatterns: Record<string, string[]> = {}; async extractFromPath(targetPath: string, actions: ClintAction[], includePatterns: string[], excludePatterns: string[]): Promise<void> { const stats = fs.statSync(targetPath); if (stats.isFile()) { if (targetPath.endsWith('actions.graphql')) { await this.extractFromFile(targetPath, actions); } } else if (stats.isDirectory()) { // Buscar por arquivos actions.graphql const files = await glob("**/actions.graphql", { cwd: targetPath, ignore: excludePatterns, absolute: true }); for (const file of files) { await this.extractFromFile(file, actions); } } } async extractFromFile(filePath: string, actions: ClintAction[]): Promise<void> { try { const content = fs.readFileSync(filePath, 'utf-8'); const extractedActions = this.parseActionsGraphQL(content, filePath); actions.push(...extractedActions); } catch (error) { console.warn(`⚠️ Erro ao ler arquivo ${filePath}:`, error); } } private parseActionsGraphQL(content: string, filePath: string): ClintAction[] { const actions: ClintAction[] = []; const lines = content.split('\n'); let currentContext: string | null = null; // 'query', 'mutation', 'action', etc. let currentAction: string | null = null; let braceLevel = 0; let parenthesesLevel = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); const lineNumber = i + 1; // Pular linhas vazias e comentários if (!line || line.startsWith('#') || line.startsWith('//')) { continue; } // Detectar contexto: type Query { ou type Mutation { const typeMatch = line.match(/^type\s+(Query|Mutation)\s*\{/i); if (typeMatch) { currentContext = typeMatch[1].toLowerCase(); braceLevel = 1; parenthesesLevel = 0; console.log(`📋 Entrando no contexto: ${currentContext}`); continue; } // Contar chaves e parênteses para navegação na estrutura const openBraces = (line.match(/\{/g) || []).length; const closeBraces = (line.match(/\}/g) || []).length; const openParens = (line.match(/\(/g) || []).length; const closeParens = (line.match(/\)/g) || []).length; braceLevel += openBraces - closeBraces; parenthesesLevel += openParens - closeParens; if (braceLevel <= 0) { currentContext = null; currentAction = null; braceLevel = 0; parenthesesLevel = 0; } // ESTRATÉGIA 1: Fields dentro de type Query/Mutation (MELHORADA) if (currentContext && braceLevel > 0) { // Padrão: fieldName(args...): ReturnType const fieldMatch = line.match(/^\s*([a-z][a-zA-Z0-9_]*)\s*(\([^)]*\))?\s*:\s*([a-zA-Z0-9\[\]!]+)/); if (fieldMatch) { const fieldName = fieldMatch[1]; currentAction = fieldName; this.addActionIfValid(actions, fieldName, filePath, lineNumber, `${currentContext} field`); // NOVO: Analisar argumentos da função para detectar campos internos const argsString = fieldMatch[2]; if (argsString) { this.parseFieldArguments(argsString, fieldName, actions, filePath, lineNumber); } continue; } // Padrão: fieldName { ... } (bloco de propriedades) const blockFieldMatch = line.match(/^\s*([a-z][a-zA-Z0-9_]*)\s*\{/); if (blockFieldMatch && braceLevel === 1) { const fieldName = blockFieldMatch[1]; currentAction = fieldName; this.addActionIfValid(actions, fieldName, filePath, lineNumber, `${currentContext} block`); continue; } // NOVO: Detectar campos internos de mutations/queries (propriedades de retorno) if (currentAction && braceLevel > 1) { const innerFieldMatch = line.match(/^\s*([a-z][a-zA-Z0-9_]*)(\s*:\s*[a-zA-Z0-9\[\]!]+)?/); if (innerFieldMatch) { const innerField = innerFieldMatch[1]; const combinedFieldName = `${currentAction}_${innerField}`; this.addActionIfValid(actions, combinedFieldName, filePath, lineNumber, `${currentContext} inner field`); } } } // ESTRATÉGIA 2: Actions independentes (formato Hasura) if (!currentContext) { // action_name { const actionMatch = line.match(/^\s*([a-z][a-z0-9_]*)\s*\{/); if (actionMatch) { const actionName = actionMatch[1]; this.addActionIfValid(actions, actionName, filePath, lineNumber, 'hasura action'); continue; } // "action_name": { ou action_name: { const propertyActionMatch = line.match(/^\s*["']?([a-z][a-z0-9_]*)["']?\s*:\s*\{/); if (propertyActionMatch) { const actionName = propertyActionMatch[1]; this.addActionIfValid(actions, actionName, filePath, lineNumber, 'property action'); continue; } // action_name(args...): Type const functionActionMatch = line.match(/^\s*([a-z][a-z0-9_]*)\s*\([^)]*\)\s*:\s*[a-zA-Z]/); if (functionActionMatch) { const actionName = functionActionMatch[1]; this.addActionIfValid(actions, actionName, filePath, lineNumber, 'function action'); continue; } } } console.log(`🔍 Extraídas ${actions.length} actions/patterns de ${filePath}`); console.log(`📊 Actions únicas detectadas: ${Object.keys(this.actionPatterns).length}`); return actions; } /** * NOVA FUNCIONALIDADE: Analisar argumentos de campos para detectar subcampos */ private parseFieldArguments(argsString: string, parentField: string, actions: ClintAction[], filePath: string, lineNumber: number): void { // Extrair nomes de argumentos: (userId: ID!, input: UserInput!) const argMatches = argsString.matchAll(/([a-z][a-zA-Z0-9_]*)\s*:\s*[^,)]+/g); for (const match of argMatches) { const argName = match[1]; // Criar padrões combinados para argumentos específicos const combinedName = `${parentField}_${argName}`; this.addActionIfValid(actions, combinedName, filePath, lineNumber, 'field argument'); } } private addActionIfValid(actions: ClintAction[], actionName: string, filePath: string, lineNumber: number, context: string): void { // Filtrar palavras reservadas e contextos inválidos (expandida) const reservedWords = [ // GraphQL keywords 'query', 'mutation', 'subscription', 'type', 'input', 'enum', 'interface', 'union', 'scalar', // Palavras muito genéricas que aparecem 252 vezes 'id', 'name', 'email', 'data', 'info', 'status', 'value', 'result', 'response', 'error', 'success', 'message', 'code', 'timestamp', 'created', 'updated', 'deleted', 'active', // Tipos comuns 'string', 'int', 'float', 'boolean', 'date', 'datetime', 'json', 'object', 'array', // Palavras específicas que podem aparecer muito 'input', 'output', 'request', 'response', 'params', 'args', 'config', 'settings' ]; const normalizedName = actionName.toLowerCase(); // Pular se for palavra reservada if (reservedWords.includes(normalizedName)) { return; } // Pular se for muito curto (provavelmente genérico) if (actionName.length < 3) { return; } // Pular se contém apenas números (IDs genéricos) if (/^\d+$/.test(actionName)) { return; } // Gerar MÚLTIPLOS padrões possíveis para busca const patterns = this.generateAllPossiblePatterns(actionName); // Armazenar patterns para o método getActionPatterns (sem duplicatas) if (!this.actionPatterns[actionName]) { this.actionPatterns[actionName] = patterns; console.log(`📋 Action ${actionName}${patterns.length} padrões: ${patterns.slice(0, 3).join(', ')}${patterns.length > 3 ? '...' : ''} [${context}]`); } for (const pattern of patterns) { actions.push({ name: actionName, pattern: pattern, file: filePath, line: lineNumber }); } } /** * Gera TODOS os padrões possíveis para uma action (EXPANDIDO PARA 252 MUTATIONS) * owner_get_name → [clint.owner.getName, ownerGet.name, owner.getThis, etc] * user_create_account_input → [clint.user.createAccountInput, userCreateAccount.input, etc] */ private generateAllPossiblePatterns(actionName: string): string[] { const patterns: string[] = []; const parts = actionName.split('_'); if (parts.length < 2) { // Se não tem underscore, criar padrões básicos patterns.push(`clint.${actionName}`); patterns.push(actionName); patterns.push(`${actionName}_action`); patterns.push(`action.${actionName}`); return patterns; } // Para lidar com as 252 mutations, criar padrões mais específicos const [entity, action, ...extraParts] = parts; // Combinar partes extras se existirem const actionWithExtras = extraParts.length > 0 ? `${action}_${extraParts.join('_')}` : action; const actionCamelCase = this.toCamelCase(actionWithExtras); const fullActionCamelCase = this.toCamelCase(parts.slice(1).join('_')); // 1. Padrão clint.entity.method patterns.push(`clint.${entity}.${actionCamelCase}`); // 2. Padrão clint.entityMethod patterns.push(`clint.${entity}${this.capitalizeFirst(actionCamelCase)}`); // 3. Padrão entityMethod patterns.push(`${entity}${this.capitalizeFirst(actionCamelCase)}`); // 4. Padrão entity.method patterns.push(`${entity}.${actionCamelCase}`); // 5. Padrão entity.action_property (original) patterns.push(`${entity}.${actionWithExtras}`); // 6. Padrão entity_action_property (snake_case completo) patterns.push(actionName); // 7. Padrão entity.action.property (com pontos) patterns.push(`${entity}.${parts.slice(1).join('.')}`); // NOVOS PADRÕES para campos específicos das 252 mutations: // 8. Padrão action.entity (invertido) patterns.push(`${fullActionCamelCase}.${entity}`); // 9. Padrão entityAction (sem separador) patterns.push(`${entity}${this.capitalizeFirst(fullActionCamelCase)}`); // 10. Padrão específico para argumentos/campos compostos if (extraParts.length > 0) { patterns.push(`${entity}.${action}.${extraParts.join('.')}`); patterns.push(`${entity}${this.capitalizeFirst(action)}.${extraParts.join('.')}`); } return patterns; } private toCamelCase(str: string): string { return str.split('_').map((part, index) => index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1) ).join(''); } private capitalizeFirst(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1); } /** * Converte nome de action do Hasura para padrão Clint * Exemplo: owner_get_name -> clint.owner.getName */ actionToClintPattern(actionName: string): string { const parts = actionName.split('_'); if (parts.length < 3) { return `clint.${actionName}`; } const [entity, action, ...details] = parts; const methodName = action + details.map(d => d.charAt(0).toUpperCase() + d.slice(1) ).join(''); return `clint.${entity}.${methodName}`; } /** * Converte padrão Clint para nome de action do Hasura * Exemplo: clint.owner.getName -> owner_get_name */ clintPatternToAction(pattern: string): string { const match = pattern.match(/^clint\.([^.]+)\.([^.]+)$/); if (!match) { return pattern.replace('clint.', ''); } const [, entity, method] = match; // Converter camelCase para snake_case const actionPart = method.replace(/([A-Z])/g, '_$1').toLowerCase(); return `${entity}_${actionPart}`; } getActionPatterns(): Record<string, string[]> { return this.actionPatterns; } } export { ClintAction };