ADRIANOLAUREANO← Artigos
SÉRIE CRIVO · 02/03RUSTANÁLISE SEMÂNTICA

Como um linter encontra problemas que o compilador aceita?

Uma linha isolada parece correta. O problema só aparece quando seguimos o nome até sua declaração — ou o caminho do programa até um ponto que jamais será alcançado.

ARTIGO 22 · CRIVO 0.2 · NOMES, ESCOPOS E CAMINHOS

5regras semânticas

3camadas de análise

0programas executados

CRIVO · O CÓDIGO VISTO POR DENTRO
FONTE PULSOse ativo == verdadeiro {
 mostre(total)
}
CRIVO 0.2estrutura · relações · intenção
DIAGNÓSTICOcondição redundanteativo == verdadeiro
──────┬──────────
simplifique para ativo

VOCABULÁRIO VISUAL

As peças que entram em cena neste capítulo.

01Símbolonome ligado a uma declaração
02Escoporegião onde um nome é visível
03Sombreamentonova declaração escondendo outra
04Semânticarelações e significado além da forma
05Bloco básicosequência sem desvio interno
06CFGmapa dos caminhos possíveis de controle
Os nomes técnicos aparecem depois do problema que resolvem — use este mapa para consultar, não para decorar.
00
O Crivo 0.2 começa exatamente onde o 0.1 terminou.
Nada é refeito nem abandonado

Lexer, parser, AST com spans, diagnósticos e as três regras locais continuam funcionando. O novo problema é relacional: saber que um nome existe não basta; precisamos descobrir qual declaração aquele uso alcança.

Crivo 0.1
fonte → tokens → AST → regras locais
                         │
Crivo 0.2 acrescenta     ├─ escopos
                         ├─ resolução de nomes
                         └─ estado de fluxo

O pacote 0.2 contém todo o projeto anterior. Rodar os testes antigos é parte da definição de “evoluir”: uma versão nova que perde regras não é evolução.

00
Escopo vira uma estrutura, não uma explicação abstrata.
Uma pilha acompanha a entrada e a saída de blocos
#[derive(Default)]
struct Scope {
    declarations: BTreeMap<String, Span>,
    used: BTreeSet<String>,
    assigned: BTreeMap<String, Span>,
}

struct Analyzer {
    scopes: Vec<Scope>,
    diagnostics: Vec<Diagnostic>,
}

Ao entrar numa função ou bloco, empilhamos um Scope. Ao sair, removemos exatamente aquele mapa. Para resolver desconto, percorremos a pilha do escopo mais interno para o mais externo. A primeira declaração encontrada é a resposta.

global     { taxa }
função    { total, desconto }
if        { desconto }  ← uso encontra este primeiro
             ↑
       shadowed-variable

O aviso de sombreamento nasce antes da inserção: consultamos escopos externos e mostramos tanto a declaração nova quanto a origem escondida. Nomes iguais em funções independentes não produzem aviso porque suas pilhas não coexistem.

00
Ler e escrever são eventos diferentes.
O estado mínimo para regras semânticas

Visitar x = 10 registra uma escrita. Visitar mostre(x) registra uma leitura. Se uma segunda escrita substitui a primeira antes de qualquer leitura, o Crivo pode apontar overwritten-assignment. Se o nome nunca é lido até o fim do escopo, produzimos unused-variable ou unused-parameter.

fun calcular(total) {        // total: declarado
    var resultado = 0;      // resultado: escrita #1
    resultado = total * 2;  // total: lido; escrita #1 descartada
    retorne resultado;      // resultado: lido
}

Essa análise é deliberadamente conservadora. Quando um caminho condicional impede provar que a atribuição foi substituída em todos os casos, o Crivo fica calado. Um falso negativo é preferível a uma correção confiante baseada numa hipótese falsa.

00
Retornar muda o estado do caminho.
Alcançabilidade e contratos de função

Depois de retorne, as instruções seguintes do mesmo caminho não podem ser alcançadas. O analyzer carrega um estado simples — Continuing ou Returned — e não tenta “executar” a condição.

fun dobro(x) {
    retorne x * 2;
    mostre("nunca"); // unreachable-code
}

Nos ramos de um se, só declaramos o trecho seguinte inalcançável quando todos os ramos retornam. A regra inconsistent-return compara caminhos que devolvem valor com caminhos que encerram sem valor. Laços permanecem conservadores porque, sem executar, não sabemos se terão zero, uma ou infinitas iterações.

00
As novas regras aparecem sem apagar as antigas.
Execução e regressão
$ cargo test
$ cargo run -- check exemplos/inicio.pulso

aviso[shadowed-variable]
aviso[unreachable-code]
aviso[unused-variable]
aviso[redundant-boolean]

Os testes são divididos em lexer, parser, regras locais, escopos e integração da CLI. Uma fixture chamada nao_executa.pulso contém uma chamada com efeito destrutivo fictício; o teste confirma que analisar a AST não chama essa função nem cria arquivos.

01
O problema mora entre as linhas.
Quando a inspeção local deixa de bastar
fun calcular(total) {
    var desconto = 10;
    se total > 100 {
        var desconto = 20;
        retorne total - desconto;
    }
    retorne total;
    mostre("fim");
}

Cada linha é válida. Em conjunto, descobrimos que a segunda variável esconde a primeira, que desconto externo nunca participa do cálculo e que a impressão após retorne é inalcançável. O Crivo 0.1 visita nós; o 0.2 precisa guardar relações.

02
Um nome é uma pergunta; a declaração é a resposta.
Tabela de símbolos e resolução

Quando o parser encontra desconto, ele sabe que é um identificador, mas ainda não sabe qual declaração esse uso representa. A tabela de símbolos registra nome, tipo de declaração, span, escopo e contadores de leitura e escrita.

pub struct Symbol {
    pub name: String,
    pub declared_at: Span,
    pub kind: SymbolKind,
    pub reads: usize,
    pub writes: Vec<Span>,
}

pub struct Scope {
    pub parent: Option<ScopeId>,
    pub symbols: HashMap<String, SymbolId>,
}
escopo #0 · módulo
└─ calcular → função
   escopo #1 · parâmetros e corpo
   ├─ total → parâmetro
   ├─ desconto → var (linha 2)
   └─ escopo #2 · bloco do se
      └─ desconto → var (linha 4)
                  ▲
uso na linha 5 procura #2 antes de #1
Resolver um nome é caminhar do escopo atual para os pais até encontrar a declaração mais próxima.

Essa regra também explica closures: um uso não encontrado no escopo da função pode ser resolvido num escopo externo e virar captura. O artigo de closures da Pulso mostra como o runtime preserva esse valor; aqui apenas identificamos a relação.

03
Forma e significado são camadas diferentes.
Análise sintática versus semântica
CamadaPerguntaProduto
Sintáticaos tokens formam uma construção válida?AST
Semântica de nomesa qual declaração cada nome se refere?AST + símbolos resolvidos
Fluxopor quais caminhos a execução poderia passar?grafo de controle

O linter continua sem executar. “Caminho possível” não significa simular valores reais; significa construir uma representação conservadora das transferências de controle.

04
Para achar código morto, desenhe as estradas.
Blocos básicos e grafo de fluxo de controle

Uma sequência sem salto no meio forma um bloco básico. Uma condição cria duas saídas. retorne encerra o caminho. Ligamos esses blocos por arestas e obtemos um grafo de fluxo de controle, ou CFG.

       [entrada]
           │
   [total > 100?]
      ╱         ╲
   sim           não
    │             │
[retorne total-20] [retorne total]
    └──────╳──────┘
           │ nenhuma aresta
      [mostre("fim")]
           ▲ inalcançável
Alcançabilidade é uma busca no grafo a partir da entrada. O bloco sem caminho de entrada é código morto.
fn reachable(cfg: &Cfg) -> HashSet<BlockId> {
    let mut seen = HashSet::new();
    let mut work = vec![cfg.entry];
    while let Some(block) = work.pop() {
        if seen.insert(block) { work.extend(cfg.successors(block)); }
    }
    seen
}
05
Cinco regras nascem das novas relações.
Cada estrutura paga seu próprio custo

Variável sombreada

Ao declarar um nome, procuramos o mesmo nome nos pais. O diagnóstico aponta as duas declarações. O Crivo permite prefixo _ quando o sombreamento é deliberado.

Parâmetro nunca utilizado

Parâmetros são símbolos no primeiro escopo da função. Leituras resolvidas alimentam seu contador; zero leituras produz aviso.

Código inalcançável

O CFG marca blocos visitáveis desde a entrada. Um comando no primeiro bloco não visitado recebe um único diagnóstico, evitando poluir cada linha morta.

Retorno inconsistente

Se um caminho da função retorna valor e outro alcança a saída implícita, o Crivo aponta a bifurcação. Ele não adivinha tipos complexos: compara a forma dos retornos possíveis.

Valor atribuído antes de ser substituído

Propagamos, por bloco, o conjunto de últimas escritas ainda não lidas. Uma nova escrita mata a anterior. Em junções usamos a união conservadora; se houver dúvida sobre uma leitura, não avisamos.

06
Um linter útil sabe quando ficar calado.
Falsos positivos e escolhas conservadoras

Uma análise pode não conhecer funções externas, reflexão ou efeitos futuros da linguagem. O Crivo prefere perder um aviso a acusar um problema que não consegue sustentar. Loops são tratados como ciclos no CFG; a análise itera até o estado parar de mudar, mas aplica um limite e abandona a regra quando não converge.

Contrato de honestidade

Cada diagnóstico registra a evidência usada. Regras que dependem de hipóteses incertas são informativas ou ficam desabilitadas por padrão.

07
Prove que analisar não é executar.
Testes como fronteira arquitetural
#[test]
fn analise_nao_chama_runtime() {
    let runtime = PanicIfExecuted;
    let program = parse("fun f(x) { retorne x; }");
    let report = Analyzer::new().analyze(&program);
    assert!(report.executed_programs().is_zero());
    drop(runtime); // nenhum método de execução foi chamado
}

Mais importante que o objeto sentinela é a dependência: o crate crivo_analysis depende do frontend, não da VM. O grafo de pacotes impede a chamada por construção.

$ cargo test
28 testes aprovados
5 snapshots de diagnóstico estáveis
0 dependências de pulso_vm em crivo_analysis
08
Crivo 0.2 enxerga nomes, ambientes e caminhos.
Da árvore local ao programa relacionado

A AST continua sendo a entrada, agora enriquecida por resolução de símbolos e um CFG por função. Com isso, regras deixam de adivinhar pela aparência. O próximo passo muda a pergunta: depois de localizar e explicar um problema, como alterar os bytes do arquivo sem apagar comentários, bagunçar a formatação ou aplicar duas correções incompatíveis?

PROJETO COMPLETO · ESTADO EXATO DESTE CAPÍTULO

Baixe e execute o Crivo 0.2.

O pacote é independente, inclui o código-fonte em Rust, exemplos Pulso, testes e README. Ele contém somente o que foi construído até este artigo; as versões anteriores permanecem disponíveis em seus próprios endereços.

Baixar Crivo 0.2 (.zip)Rust · Pulso · análise estática · testes

O que este artigo fez você pensar?

Dúvidas, experiências e contrapontos ajudam a próxima pessoa a enxergar o assunto por outro ângulo.

Todos passam por moderação. Ao enviar, você concorda com a política de privacidade.

Receba os próximos artigos.

Uma mensagem quando uma nova investigação estiver pronta. Só isso.