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
text/typescript
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 };