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.
se ativo == verdadeiro {
mostre(total)
}ativo == verdadeiro
──────┬──────────
simplifique para ativoVOCABULÁRIO VISUAL
As peças que entram em cena neste capítulo.
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 fluxoO 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.
#[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-variableO 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.
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.
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.
$ 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.
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.
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 #1Essa 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.
| Camada | Pergunta | Produto |
|---|---|---|
| Sintática | os tokens formam uma construção válida? | AST |
| Semântica de nomes | a qual declaração cada nome se refere? | AST + símbolos resolvidos |
| Fluxo | por 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.
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çávelfn 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
}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.
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.
Cada diagnóstico registra a evidência usada. Regras que dependem de hipóteses incertas são informativas ou ficam desabilitadas por padrão.
#[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_analysisA 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.