Análise estática avançada com ts-morph

1. Introdução ao ts-morph e seus fundamentos

O ts-morph é uma biblioteca que fornece uma API wrapper sobre o compilador TypeScript, simplificando drasticamente a análise e manipulação de código fonte. Enquanto o módulo ts nativo do TypeScript oferece acesso direto ao compilador, sua API é extremamente verbosa e de baixo nível. O ts-morph abstrai essa complexidade, oferecendo uma interface mais expressiva e orientada a objetos.

Diferenças fundamentais:

Aspecto ts nativo ts-morph
Criação de AST Requer múltiplas chamadas Project.createSourceFile()
Navegação ts.forEachChild() node.getChildren()
Manipulação Complexa, com factories Métodos intuitivos como replaceWithText()

Instalação e configuração básica:

import { Project, SourceFile } from "ts-morph";

const project = new Project({
  tsConfigFilePath: "tsconfig.json",
  addFilesFromTsConfig: true,
});

// Ou criar manualmente um arquivo fonte
const sourceFile = project.createSourceFile(
  "exemplo.ts",
  `
    function saudacao(nome: string): string {
      return "Olá, " + nome;
    }
  `
);

console.log(sourceFile.getText());

O objeto Project gerencia todo o contexto de análise, incluindo arquivos, dependências e configurações do TypeScript.

2. Navegando na AST com ts-morph

A Árvore Sintática Abstrata (AST) é a estrutura central do ts-morph. Cada elemento do código é um Node, classificado por SyntaxKind.

// Navegação básica
const functionDecl = sourceFile.getFunction("saudacao");
if (functionDecl) {
  console.log("Nome da função:", functionDecl.getName());
  console.log("Parâmetros:", functionDecl.getParameters().map(p => p.getName()));

  // Navegação hierárquica
  const body = functionDecl.getBody();
  const returnStatement = body?.getDescendantsOfKind(
    SyntaxKind.ReturnStatement
  );

  // Filtragem com predicados customizados
  const strings = body?.getDescendants().filter(node => 
    node.getKind() === SyntaxKind.StringLiteral
  );
}

Métodos essenciais de navegação:
- getChildren() — retorna nós filhos diretos
- getParent() — sobe um nível na hierarquia
- getDescendants() — todos os descendentes recursivamente
- getDescendantsOfKind(kind) — filtragem por tipo específico

3. Manipulação e transformação de código fonte

Uma das capacidades mais poderosas do ts-morph é a manipulação programática do código.

// Adicionando imports
const arquivo = project.getSourceFileOrThrow("src/app.ts");
arquivo.addImportDeclaration({
  moduleSpecifier: "./utils",
  namedImports: ["formatDate", "parseNumber"],
});

// Inserindo statements
const funcao = arquivo.getFunction("processData");
funcao?.insertStatements(0, [
  "if (!data) throw new Error('Dados inválidos');",
  "const validated = validateInput(data);",
]);

// Substituição de nós
const oldVar = arquivo.getVariableDeclaration("oldName");
oldVar?.replaceWithText("const newName = 42");

// Renomeação automatizada
const symbols = arquivo.getExportedDeclarations();
symbols.forEach((declarations, name) => {
  if (name.startsWith("deprecated_")) {
    declarations.forEach(decl => decl.rename(name.replace("deprecated_", "")));
  }
});

4. Análise de tipos e inferência

O ts-morph expõe o sistema de tipos do TypeScript de forma acessível.

const sourceFile = project.createSourceFile(
  "tipos.ts",
  `
    interface Usuario {
      id: number;
      nome: string;
      email: string;
    }

    function buscarUsuario(id: number): Usuario {
      // implementação
    }

    const usuario: Usuario = { id: 1, nome: "João", email: "joao@email.com" };
  `
);

// Obtendo tipos de variáveis
const usuarioVar = sourceFile.getVariableDeclaration("usuario");
const tipoUsuario = usuarioVar?.getType();
console.log("Propriedades do tipo:", tipoUsuario?.getProperties().map(p => p.getName()));

// Análise de funções
const funcao = sourceFile.getFunction("buscarUsuario");
const tipoRetorno = funcao?.getReturnType();
console.log("Tipo de retorno:", tipoRetorno?.getText());

// Verificação de compatibilidade
const tipoNumber = project.getTypeChecker().getTypeAtLocation(
  sourceFile.getFirstDescendantByKind(SyntaxKind.NumberKeyword)!
);
console.log("É string?", tipoNumber.isString()); // false
console.log("É número?", tipoNumber.isNumber());  // true

5. Busca e consulta de código em larga escala

Para projetos reais, o ts-morph permite análise em múltiplos arquivos.

// Projeto completo
const project = new Project({
  tsConfigFilePath: "tsconfig.json",
  addFilesFromTsConfig: true,
});

// Consultas em todo o projeto
const allFunctions = project.getSourceFiles()
  .flatMap(sf => sf.getFunctions())
  .filter(fn => fn.getParameters().length > 5);

// Encontrando referências
const minhaClasse = project.getSourceFile("minhaClasse.ts")
  ?.getClass("MinhaClasse");

if (minhaClasse) {
  const referencias = minhaClasse.findReferencesAsNodes();
  console.log(`Referências encontradas: ${referencias.length}`);

  // Definições
  const definicoes = minhaClasse.getDefinitions();
  definicoes.forEach(def => {
    console.log(`Definido em: ${def.getSourceFile().getFilePath()}`);
  });
}

// Exportações
const exports = project.getSourceFileOrThrow("index.ts")
  .getExportedDeclarations();
exports.forEach((decls, name) => {
  console.log(`Exportado: ${name} - ${decls.length} declarações`);
});

6. Criação de ferramentas de análise personalizadas

O ts-morph é ideal para construir ferramentas de análise estática sob medida.

// Lint customizado: proibir console.log
function lintConsoleLogs(project: Project): string[] {
  const erros: string[] = [];

  project.getSourceFiles().forEach(sf => {
    const consoleCalls = sf.getDescendantsOfKind(SyntaxKind.CallExpression)
      .filter(call => {
        const expr = call.getExpression();
        return expr.getText() === "console.log";
      });

    consoleCalls.forEach(call => {
      erros.push(
        `${sf.getFilePath()}:${call.getStartLineNumber()} - console.log não permitido`
      );
    });
  });

  return erros;
}

// Geração de métricas
interface Metricas {
  arquivo: string;
  funcoes: number;
  complexidadeMedia: number;
  coberturaTipos: number;
}

function gerarMetricas(project: Project): Metricas[] {
  return project.getSourceFiles().map(sf => {
    const funcoes = sf.getFunctions();
    const totalParams = funcoes.reduce((acc, fn) => 
      acc + fn.getParameters().length, 0
    );
    const paramsTipados = funcoes.reduce((acc, fn) => 
      acc + fn.getParameters().filter(p => p.getType().getText() !== "any").length, 0
    );

    return {
      arquivo: sf.getFilePath(),
      funcoes: funcoes.length,
      complexidadeMedia: funcoes.length > 0 ? totalParams / funcoes.length : 0,
      coberturaTipos: totalParams > 0 ? (paramsTipados / totalParams) * 100 : 100,
    };
  });
}

// Integração CLI
const erros = lintConsoleLogs(project);
const metricas = gerarMetricas(project);

console.log("Erros encontrados:", erros.length);
console.log("Métricas:", JSON.stringify(metricas, null, 2));

7. Boas práticas e armadilhas comuns

Performance:

// Evite processamento redundante
const sourceFile = project.getSourceFileOrThrow("grande.ts");

// Ruim: percorre várias vezes
const vars = sourceFile.getVariableDeclarations();
const funcs = sourceFile.getFunctions();

// Bom: cache de resultados
const allDeclarations = sourceFile.getDescendants();
const vars2 = allDeclarations.filter(n => n.getKind() === SyntaxKind.VariableDeclaration);
const funcs2 = allDeclarations.filter(n => n.getKind() === SyntaxKind.FunctionDeclaration);

// Lazy loading com Project
const project = new Project({
  useInMemoryFileSystem: true,
  skipLoadingLibFiles: true, // Para projetos grandes
});

Lidando com erros:

try {
  const sourceFile = project.getSourceFileOrThrow("inexistente.ts");
} catch (error) {
  console.error("Arquivo não encontrado no projeto");
}

// Código inválido
const sourceFile = project.createSourceFile("invalido.ts", "const x = ;");
const diagnostics = project.getPreEmitDiagnostics();
diagnostics.forEach(diag => {
  console.log(`Erro: ${diag.getMessageText()}`);
});

Limitações importantes:

  • ts-morph não executa TypeScript, apenas analisa sintaticamente
  • Para análise semântica completa, ainda depende do compilador TypeScript
  • Projetos muito grandes podem exigir otimizações de memória
  • Algumas transformações complexas podem ser mais fáceis com o ts nativo

Referências