#AI#TypeScript

Code Intelligence: Combining AST and LLM

LLMs alone have limits understanding code—they see text, not precise structure. Pairing an AST (Abstract Syntax Tree) with an LLM enables more accurate code search, refactoring, review, and completion. This article covers why AST matters, AST + LLM patterns, the tree-sitter/LSP toolchain, and how it makes AI coding tools more reliable.

Key Points

  • Topic Focus: AI、TypeScript
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

1. Where LLMs Alone Fall Short at Understanding Code

Hand an LLM a snippet of TypeScript and ask it to "find all call sites of the getUser function," and it will confidently produce a list. The problem is that these results are often wrong.

It makes these kinds of mistakes:

  • Treating getUser in comments, strings, and variable names as calls. A doc line saying "call getUser() to fetch" is taken at face value.
  • Conflating symbols with the same name but different scopes. A getUser in a.ts and a getUser in b.ts are assumed to be the same one.
  • Missing type mismatches. Ask "can I pass User here?" and it guesses—it doesn't know the parameter is actually typed User | null.
  • Incomplete or incorrect refactors. Renaming? It updates the definition but misses the dynamic call obj[name](). Changing a signature? It edits call sites without realizing a mock file also implements that interface.

There's one root cause: LLMs see text, not structure. A token sequence is just a string of characters; it has no deterministic knowledge of "which declaration this identifier binds to," "what type this expression is," or "which lines read this variable." It's good at semantic understanding (roughly what this code does) but poor at precise structural reasoning (exactly which symbol this refers to).

Yet structural information is precisely the skeleton of code. When the skeleton is wrong, the answer becomes "looks right but isn't"—the most dangerous kind of error, because it's delivered with confidence.

2. What AST Adds: Structure Precise to the Byte

An AST (Abstract Syntax Tree) is the tree-shaped representation of parsed source code. A line like const x = f(1) is broken into VariableDeclaration → Identifier(x) → CallExpression → Identifier(f) → NumericLiteral(1). Each node knows its type, position (row/column offsets), and parent/child relationships.

More importantly, mature AST toolchains provide two kinds of derived information:

  1. Reference resolution: which declaration node a given Identifier node binds to, and which places reference a given declaration.
  2. Types and scopes: the static type of an expression, whether a name is visible in the current scope, and what it binds to.

This information is deterministic, reproducible, and hallucination-free. An AST never "feels" that a line might be a call—it's either a CallExpression or it isn't; the byte offsets are written there and don't change.

The semantic understanding LLMs excel at and the structural reasoning ASTs excel at are perfectly complementary. That's the starting point of value for the AST + LLM combination.

3. Four Patterns for Combining AST and LLM

The combinations I reach for again and again in practice essentially fall into four patterns. They share one core idea: let the AST do the "deterministic" work, let the LLM do the "understanding and generation" work, and bridge them with structured data.

Pattern 1: Structural filtering + semantic search (code search). First use the AST to narrow the candidate set to "real identifier references," stripping out comments/strings/homonymous noise, then hand this trimmed context to the LLM for a semantic judgment ("which of these calls are actually calling the user service"). The result is both precise and intent-aware.

Pattern 2: Precise context + LLM refactoring (rename / change signature). Use the AST to find every real reference point, feed those along with the definition to the LLM, and let the LLM generate a new name / new signature while keeping things consistent. The AST guarantees "nothing is missed"; the LLM guarantees "it's named well."

Pattern 3: Structural anomalies + LLM review (code review). The AST detects structural-level smells (excessive nesting, duplicated structures, unused exports, circular dependencies), and the LLM takes those structural facts and judges "why it's bad and how to fix it." Structural signals stop the LLM from going on vibes.

Pattern 4: Type awareness + LLM completion. Use the TS Compiler API or LSP to obtain the expected type at the cursor position, visible symbols, and in-scope variables, and feed them to the LLM as constraints. Filter the LLM's completion candidates through type checking again, and you can virtually eliminate "looks plausible but has the wrong type" hallucinations.

Let's look at the toolchain and the practice in turn.

4. The Toolchain: tree-sitter, LSP, TS Compiler API

The three each cover a segment; combined, they cover nearly every language scenario.

tree-sitter: multi-language incremental parsing. It's GitHub's parser generator with grammars for 100+ languages; parsing is fast, supports incremental parsing (editing one character doesn't require a full re-parse), and outputs a unified S-expression-style AST. Its strengths are cross-language, stable, fast; its weakness is that it does no reference resolution or type inference—it gives you the syntax tree but not "who this x binds to." So tree-sitter is suited for tasks like "structural filtering" and "smell detection" that only need syntactic structure.

javascript
// tree-sitter: find all real getUser calls in the file
const Parser = require('web-tree-sitter');
const { TypeScript } = require('tree-sitter-typescript');

const parser = new Parser();
parser.setLanguage(TypeScript);

const tree = parser.parse(sourceCode);
const calls = [];

function walk(node) {
  // only match function call nodes; getUser in strings/comments never enters this branch
  if (node.type === 'call_expression') {
    const fn = node.childForFieldName('function');
    if (fn?.text === 'getUser') {
      calls.push({ line: node.startPosition.row, text: node.text });
    }
  }
  for (const child of node.namedChildren) walk(child);
}
walk(tree.rootNode);
// calls now holds ironclad call points the LLM can't hallucinate

LSP (Language Server Protocol): references, definitions, hover, types. LSP is the language service protocol that originated with VS Code; nearly every mainstream language has a language server. Its killer features are textDocument/references (find references), textDocument/definition (go to definition), textDocument/hover (type/docs)—all computed from a semantic model (type system, module resolution), with precision far beyond a pure syntax tree. The cost is that you need to spin up a language server process, do LSP communication, and wait for indexing to finish. Use it when you need semantic information like "references/types."

typescript
// use LSP to fetch a symbol's real references (pseudo-code, based on vscode-languageclient)
const refs = await languageClient.sendRequest(
  'textDocument/references',
  {
    textDocument: { uri: fileUri },
    position: { line: 10, character: 8 }, // at the definition of getUser
    context: { includeDeclaration: false },
  },
);
// refs are deduplicated, cross-file, semantically bound real references
// extract the content at these positions—that's the precise context for the LLM

TS Compiler API: TypeScript's ultimate weapon. It's the compiler API bundled with the typescript package; it gives you the type checker (checker), symbol table, types, and control-flow analysis results. It's the most precise of the three, but only serves TS/JS. For type-aware completion or complex refactors, it's the first choice.

typescript
import ts from 'typescript';

const program = ts.createProgram(['user.ts'], { strict: true });
const checker = program.getTypeChecker();
const sf = program.getSourceFile('user.ts')!;

// traverse each node to get the symbol's real type—something the LLM can never guess accurately
ts.forEachChild(sf, function visit(node) {
  if (ts.isIdentifier(node)) {
    const sym = checker.getSymbolAtLocation(node);
    if (sym) {
      const type = checker.getTypeOfSymbolAtLocation(sym, node);
      // checker.typeToString(type) => "User | null"
      // inject the type info into the LLM's prompt and completion/review hallucinations drop sharply
    }
  }
  ts.forEachChild(node, visit);
});

The selection rule, in one sentence: just syntax structure → tree-sitter; references/jump-to/hover → LSP; types/symbols in TS projects → TS Compiler API. The three aren't mutually exclusive; they layer: tree-sitter does coarse filtering, LSP/TS does precise localization, and the LLM makes the final semantic decision.

5. In Practice: Use the AST to Find References, Feed the LLM to Rename

The requirement is typical: rename getUser to fetchUser, changing everything and changing it correctly (without touching unrelated same-name methods like userCache.getUser).

The typical LLM-only train wreck: missing dynamic calls, mistakenly editing sample code in comments, and rewriting other modules' getUser along with it.

The correct approach is AST finds references + LLM picks the new name + AST applies it:

typescript
// Step 1: use the TS Compiler API to find all "semantic-level" references to getUser
import ts from 'typescript';

const program = ts.createProgram(['svc.ts', 'api.ts', 'cache.ts'], { strict: true });
const checker = program.getTypeChecker();
const edits: { file: string; span: ts.TextSpan; }[] = [];

for (const sf of program.getSourceFiles()) {
  if (sf.isDeclarationFile) continue;
  ts.forEachChild(sf, function visit(node) {
    if (ts.isIdentifier(node) && node.text === 'getUser') {
      const sym = checker.getSymbolAtLocation(node);
      // Key: only accept references bound to "that" getUser declaration
      // The same-named cache.getUser() method binds to a different symbol and is automatically excluded
      if (sym?.valueDeclaration?.name?.getText() === 'getUser'
          && isTargetDeclaration(sym)) {
        edits.push({
          file: sf.fileName,
          span: { start: node.getStart(), length: node.getWidth() },
        });
      }
    }
    ts.forEachChild(node, visit);
  });
}

// Step 2: feed the definition + call context to the LLM and let it give a semantically more accurate new name
const prompt = `
You are a senior engineer. I want to rename the function getUser.
Definition: ${getDefText(edits[0])}
Called from these places: ${edits.map(e => snippet(e)).join('\n')}
Based on its responsibility, give me a more accurate new name. Return only the name.
`;
const newName = await llm.complete(prompt); // => "fetchUser"

// Step 3: use the AST's precise span for replacement, instead of having the LLM spit out the whole file
const changes = edits.map(e =>
  ts.factory.createTextChange(e.span, newName));
// write back to file—zero hallucinations, zero omissions

The essence of this flow is the division of labor: "what to change" is a structural question, and the AST settles it definitively; "what to call it" is a semantic question, and the LLM names it; "how to apply it" is again structural, done via precise AST-span replacement. The LLM never touches file contents throughout—it only outputs a name, the thing it does best. The result: the right spots changed, everything changed, nothing changed wrongly, and the LLM's hallucination surface is minimized.

Compare this with the pure-LLM approach: have the model read three files and output the three modified files. It will most likely "look right"—most spots changed correctly, but with a dynamic call missed, a comment line wrongly edited, and the unrelated userCache.getUser changed as a bonus. In a real codebase, these errors are hard to find and hard to roll back. The AST + LLM combination makes the result verifiable and trustworthy.

The completion scenario works the same way: use the TS Compiler API or LSP to get the expected type User at the cursor, then feed the list of visible, type-matching symbols to the LLM for ranking—the LLM proposes candidates, and the type system filters as a backstop. Completions generated this way understand intent and won't have type errors.

Summary

  • The LLM is the textual view; the AST is the structural view. The former understands semantics but hallucinates structure; the latter understands structure but not intent. Combined, they're stable.
  • Division of labor: deterministic work goes to the AST; understanding and generation go to the LLM. "What to change" uses the AST; "what to call it / how to write it" uses the LLM; structured data bridges them.
  • Layered toolchain: tree-sitter does coarse syntactic filtering (fast, cross-language); LSP does references/jump/hover (semantic, cross-language); TS Compiler API does types/symbols (TS-only, most precise).
  • The biggest risk of a pure LLM is "looks right but isn't": confidently missing changes, wrongly editing, type errors. The value of AST + LLM is compressing the hallucination surface from "an entire file" to "a single token," making the result verifiable and trustworthy.
  • In one sentence: when building AI coding tools, first ask "which things can structure determine," lock those down, and hand the remaining semantic decisions to the LLM. The reliability of code intelligence never comes from a bigger model—it comes from more accurate structure.

The LLM is responsible for understanding what the code is saying; the AST is responsible for proving exactly where the code is and what it is. One handles being clever, the other handles being rigorous—only together do they form code intelligence truly fit for production.

Related

Related Content