← back

TL;DR / Preview

50
real-world codebases
0.84
median type-match score (0–1)
29/50
repos scored ≥ 0.80
96%
of a 13×-larger model's quality
GoalAutomatically annotate variable types across an entire codebase, so a type checker can catch the type-confusion bugs that dynamically-typed languages like Python and JavaScript otherwise ship to production.
ChallengeLLMs annotate types well within a single file, but a function's real types often depend on code in other files, and a whole repository won't fit in a model's context window.
SolutionA neuro-symbolic pipeline that pairs static analysis with an LLM: CodeQL maps the repo into a dependency graph, the LLM types each function using only the context it needs, and a validation layer overrides the model with provable static-analysis facts and adds runtime checks.
ResultAnnotates entire repositories that exceed frontier models' context windows, using models as small as 0.6B (small enough to run on a laptop) and up to 30B, at a median type-match score (TypeSim Ratio) of 0.84, all evaluated at scale on a supercomputing cluster.
Python CodeQL NetworkX vLLM Slurm / HPC Qwen3
// the thesis

Scaffolding over scale: pin an LLM to one small, well-posed task and let procedural code manage the context. Done right, a 0.6B model that runs on a laptop can annotate repositories no frontier model can even fit in its window.

Why Types Are a Security Problem

Python and JavaScript dominate web backends (Django, Flask, FastAPI, Express) and are dynamically typed — type information isn't enforced at runtime. That invites type-confusion vulnerabilities, where a program processes an object of the wrong type and misbehaves in security-relevant ways. Reliable type annotations are the foundation for catching these statically, before they ship.

LLMs are a natural annotator. They read code semantically and generalize across languages. However, the quality of any downstream type-enforcement work is capped by the quality of that first annotation pass, so the real question is whether an LLM can annotate types accurately at repository scale, not just within one file. Security is what motivates this, but it sits downstream: deciding which functions are worth guarding with runtime checks is a judgment you can only make once the types exist. Accurate repo-wide annotation is the prerequisite, and it's what this project sets out to deliver.

The Context-Window Wall

A value's type in one file is often determined by a definition in another, whether it be an imported class, a function's return type, or a parameter set by its caller. A model that sees one file at a time guesses at these and gets them wrong.

The naive fix of "dump the whole repo into the prompt" fails on real codebases. They exceed even long-context models, a failure we hit repeatedly during baseline runs. A single function's transitive dependency tree can be a directed acyclic graph (DAG) of depth 4+ that alone would exceed an LLM's context window. The framework's job is to compute exactly the context each function needs, and nothing more.

The DAG Framework

The system splits the work between two kinds of reasoning. Static analysis (CodeQL) deterministically extracts the facts it can prove: who calls what, how arguments flow, which methods are used. The LLM handles the genuinely ambiguous part: inferring a semantic type from a function's intent, where no fixed rule applies. The LLM proposes; static analysis constrains and corrects. That division is the core design.

Concretely, typing is reframed as a graph problem. Functions become nodes; caller→callee relationships become edges. Resolving the graph in dependency order means every function is typed only after the things it depends on already are, so their inferred types become grounded context for it.

A repository as a dependency DAG Functions are nodes and caller-to-callee calls are edges. Leaf callees at the bottom are typed first; their resolved types flow up to the callers that depend on them, level by level, until the top-level entry points are typed last. TYPED LAST TYPED FIRST main() load_config() run_step() read_file() get_cfg() json.load leaves roots
Functions as nodes, caller→callee as edges (gray = external/stdlib stub). Leaf callees are typed first; resolved types flow up to their callers, so each function is typed with its dependencies already known.
1 · Extract

CodeQL converts the entire repository into a queryable database, then seven queries extract a rich evidence set: function definitions, the call graph, return-value usage, parameter method/attribute accesses, local-variable assignments, developer isinstance() constraints, and call-site argument types.

2 · Build

NetworkX assembles a graph where nodes are file_path::func_name (carrying file, name, def_line, return_hints) and edges are caller→callee, annotated with argument positions. External/stdlib calls become stub nodes with file="".

3 · Condense

Real call graphs contain cycles (mutual recursion), so rather than deleting edges, strongly-connected components are condensed into single super-nodes via NetworkX, yielding a true DAG over the condensation without losing dependency information.

4 · Sort

A reverse topological sort orders the DAG so that every function is typed only after the functions it calls (its callees) are already done, so each resolved callee type is available as context when its callers are typed.

5 · Annotate

A pipeline walks the sorted DAG and has the LLM type each function in turn, accumulating known types as it goes. Each inference reuses the already-resolved types of the functions it calls.

Language-agnostic by construction. Nothing in the pipeline is bound to Python. Three choices make it portable to any language CodeQL supports (JavaScript, TypeScript, Java, C/C++, C#, Go, Ruby):
  • The extraction layer is CodeQL, which is itself multi-language. Swapping --language=python for another target reuses the same query structure.
  • The framework reasons over function signatures and call-graph edges, universal program structure, rather than language-specific local-variable syntax.
  • It rewrites the function with inline annotations instead of emitting a Python-only .pyi stub, so the output mechanism ports too.
The honest scope: the architecture is designed for any language, but it was validated on Python. JavaScript was the original target (and arguably the higher-impact one, given how much untyped JS ships) but no suitable typed/untyped JS benchmark existed to measure against. Building that dataset is the clearest next step.
The five-stage pipeline A left-to-right pipeline: CodeQL extracts evidence from the repository, NetworkX builds a dependency graph, strongly-connected components are condensed and reverse-topologically sorted, the LLM annotates each function in that order, and a static-analysis validation layer corrects the output before rewriting the source. CodeQL extract NetworkX DAG nodes + call edges condense + sort reverse topological LLM annotate callees → callers validate static-analysis guard rewrite source inline types + asserts
The pipeline end-to-end: static analysis builds and orders the graph, the LLM annotates callees-first, and a validation layer guards every prediction before the source is rewritten

What the Model Actually Sees

Each per-function prompt is assembled from graph-derived evidence rather than raw file dumps:

Source

The raw source of the single function being typed.

Call sites

How callers pass arguments into this function, attached to DAG edges before annotation.

Return usage

Every x = f(...) assignment emits a (callee, caller_file, caller_name, lhs_var, line) record, capturing how the function's output is consumed.

Known types

Types resolved for already-typed callees, accumulated as the walk proceeds.

Output

The model returns only a JSON object: {"param": "type", ..., "return": "type"}.

Design choice: the system rewrites the function with inline annotations rather than emitting a separate .pyi stub. That keeps annotations usable for downstream taint analysis and type enforcement, and is what makes the approach generalize beyond Python.

The LLM Is Guarded, Not Trusted

The model's output is treated as a fallible proposal, not ground truth. A static-analysis validation layer overrides or discards annotations whenever CodeQL evidence contradicts them:

Override

CodeQL points-to inferences (majority-voted across call sites) and developer-written isinstance() constraints take precedence over the model's guesses, static evidence wins.

Method check

If a parameter is annotated as a builtin but its observed method calls don't belong to that type (e.g. annotated list but .upper() is called), the annotation is dropped as inconsistent.

Alias check

Annotations that are actually function names masquerading as types (a common LLM error that mypy rejects) are detected via CamelCase/snake_case heuristics and removed.

Refinements

Targeted fixes encode domain rules: widen intfloat when local evidence shows float literals or division; drop None returns on generator functions; discard bare object (the model signalling "I don't know").

Enforce

Beyond annotations, runtime isinstance assertions are injected into the rewritten functions, turning inferred types into actual runtime checks. This serves as the bridge from annotation to type-confusion prevention.

An evidence threshold guards precision: a function is only annotated when there are at least two call-site arguments or explicit isinstance evidence, as a single speculative hint isn't enough. The guiding rule throughout is that an omitted annotation beats a wrong one, since a single bad cross-file type can cascade.

Before & After

A representative function, before and after the pipeline runs. Inline type annotations are added from cross-file evidence, and runtime assertions are injected for the types that can actually be checked at runtime.

before — untyped
def load_config(path, parser):
    raw = path.read_text()
    cfg = parser(raw)
    return cfg.get("settings")
after — annotated + enforced
def load_config(path: Path, parser: Callable[[str], dict]) -> dict:
    assert isinstance(path, Path)
    raw = path.read_text()
    cfg = parser(raw)
    return cfg.get("settings")
Types for path and parser are inferred from how callers pass them and how they're used. An assertion is emitted for path but not parser: a Callable signature can't be verified by isinstance, so the validation layer annotates it without asserting, enforcing only what's safely checkable at runtime.

Ablations: What Actually Helped

Feeding the model explicit type evidence drove the biggest gain. Where the code itself reveals a type (an isinstance() guard, a string method like .upper()) that evidence is extracted and injected into the DAG context. On Qwen3-30B-A3B-Instruct this single design change cut cross-file type errors (mypy-detected, lower is better) by ~37%, from 86 down to 54, providing a direct demonstration that the right scaffolding, not a bigger model, is what moves the score.

Seeding root nodes didn't. Pre-filling ground-truth types at root nodes barely improved results, and would defeat the purpose of this project, since a user would then have to hand-type a large fraction of the repo. Unseeded operation was retained.

Coder models lost to instruct models. Qwen-3 Coder variants underperformed: trained for code completion rather than instruction-following, they were worse at emitting the rigid JSON schema the pipeline demands and at honoring the prompt's constraints. Standard instruct LLMs handled the structured-output task far better.

Evaluation

Evaluated on Typybench, a benchmark of 50 well-typed Python repositories, each provided in both typed and untyped form. Inference ran en masse on the Pittsburgh Supercomputing Center cluster (batched Slurm jobs serving the Qwen3 models through vLLM) which is what made evaluating across all 50 repositories at multiple model sizes tractable.

How to read the score. The headline metric is TypeSim Ratio: of the types the system gets roughly right, how many does it get exactly right? It's exact-match similarity ÷ overall similarity over the function signatures the framework targets. A value near 1.0 means “when it's close, it's usually spot-on,” isolating real performance from partial credit. TypeCheck (the mypy-flagged type errors, lower is better) is a secondary raw-error cross-check, reported where a full readout was available.
Across all 50 repositories (Qwen3-8B)TypeSim Ratio
Median0.84
Mean0.78
Repos scoring ≥ 0.8029 / 50
Repos scoring < 0.504 / 50
Headline result — Qwen3-8B across the full benchmark. The median sits above the mean because a short tail of abstract-typing repos pulls the average down
ModelTypeSim RatioTypeCheckPred. ErrorsTruth
Qwen3-0.6B0.8400.284424
Qwen3-8B0.8560.583184
Qwen3-30B-A3B0.8720.602184
Zooming in on one repository (gptme) — Both metrics improve with model size. TypeCheck is shown here because gptme is one of the repos with a complete mypy readout

Across all 50 repositories, the small models held up remarkably well: Qwen3-8B averaged a 0.781 TypeSim Ratio and Qwen3-0.6B 0.752 (a gap of just 0.029) meaning the 0.6B model retained about 96% of the TypeSim Ratio of a model more than ten times its size. Bigger models type complex code somewhat better, but a sub-billion-parameter model running in a 32K window stays well within a usable range. The scaffolding, more than the model size, is what carries the performance.

The per-repo distribution is the useful part: the method is reliably strong across most of the benchmark, with a small, sharp tail. The top performers are well-structured, primitive-heavy libraries: pip, Pillow, flask, poetry, and faceswap all clear 0.96. The weakest are exactly the codebases built on heavy generic and abstract typing, such as pydantic (0.33), fastapi (0.37), OpenBB (0.29), where a function's “type” is often a complex parameterized generic the system doesn't yet resolve. The method excels where types are concrete and degrades where they're abstract: a clean, predictable operating envelope.

A Metric Mismatch, Diagnosed

An early puzzle: raw TypeSim trailed the Typybench paper's numbers badly, even though exact and overall TypeSim were close together, a sign the system was typing its targets correctly. Investigation revealed the cause wasn't capability but scope: the framework annotates function signatures (language-agnostic, the part that matters for cross-file flow), while Typybench's metric heavily rewards annotating bare local variables (x = 5x: int = 5), which the system deliberately didn't target.

Rather than chase the metric, the TypeSim Ratio was introduced to measure performance on what the system actually types. Then a post-hoc mypy pass would close most of the remaining gap while preserving language independence (the same trick works for JS/TS). Diagnosing this as a measurement-scope mismatch, not a model failure, was as valuable as any raw score.

An Honest Accounting

Two claims, kept separate. First: the system works. It annotates entire repositories end-to-end (including ones no baseline could load) at TypeSim Ratios that track ground truth well. That was the hard engineering goal, and it was met.

Second, the benchmark comparison: on gptme, Claude Sonnet with the full repo in context produced 8 cross-file type errors to this framework's 18, and overall the system landed near GPT-4o. It did not beat the strongest baseline on raw error count—the secondary metric. But two factors reframe what that comparison actually measures:

Contamination

gptme, the headline test repo, is a widely-used open-source project that was likely in Claude's and GPT-4o's training data. This is training-data contamination: a baseline may be recalling memorized annotations rather than inferring them, which undercuts the comparison. The fair test is unseen code, where neither side has memorized the answer. However, it is difficult to source this given the lack of knowledge on the training data of these frontier models.

Propagation

The gap that remains traces to one identifiable, fixable mechanism. Functions are typed in reverse topological order (callees before the callers that depend on them) so each resolved type becomes context for the calls above it. If an early callee is typed wrong, that mistake is inherited by every caller in its chain. TypeCheck is an unforgiving metric: a couple of bad cross-file types collapse the score. It's a scaffolding-robustness problem, not a ceiling on the approach.

How one early type error propagates through the dependency DAG Functions are typed in reverse topological order: low-level callees first, then the callers that depend on them. If an early-typed callee is given the wrong type, that type becomes context for every caller above it, so the error propagates up the call chain, while functions on an independent branch stay correct. CALLEES — TYPED FIRST CALLERS — TYPED LATER get_cfg() wrong type load_config() run() save() read() validate() format() Wrong type on an early callee poisons every caller above it Independent branch stays correct
Functions are typed callees-first. A wrong type on an early callee cascades up to every caller that depends on it. Arrows show inference order and context flow, not call direction.

The decisive contribution, though, is one the baselines structurally cannot match: the system annotates repositories that exceed any frontier model's context window, with models as small as 0.6B, where the full-context approach simply hits a wall (and rate limits) on anything beyond a few small repos.

In Summary

Core Contribution
Annotated full repos that exceeded frontier models' context windows, using a 0.6B model in 32K tokens
Annotation Quality
TypeSim Ratio averaging 0.78 (0.84 median) across 50 repos on Qwen3-8B; usable across the full 0.6B-30B range
Augmentation Gain
Explicit type signals cut cross-file errors ~37% (86→54, Qwen3-30B)
Key Finding
Early-node errors propagate through the DAG, scaffolding is powerful but brittle