Code ReViewer Extension
A VS Code / Cursor extension that parses code, builds an interactive call graph, and allows every function by filtered by 14+ code quality metrics comupted internally from the parse tree. No GenAI, no language server, no type checker. Currently in closed beta, expected full release in February 2027.
- Year
- 2026
- Category
- Static analysis, Developer tooling
- Role
- Engineering thesis co-author, Extension developement and feature design
- Stack
- TypeScript, Kotlin, ANTLR 4, VS Code Extension, Sigma.js, graphology, WebGL, GraalVM, Static Analysis, NDJSON
Term definitions20 terms
- ANTLR
- Parser generator. A community .g4 grammar becomes a lexer, a parser and a listener base class – which is why adding a third language cost us a week rather than a semester.
- call graph
- Nodes are functions and methods; directed edges are calls (caller → callee). One picture of what calls what across the files you selected.
- candidate list
- The ordered name guesses a call site makes about its own target – db.Repo.load, then app.View.load, then nothing. It is what we built instead of a type checker.
- composite metric
- A metric computed only from other metrics, never from source. Halstead Volume, Effort and Bugs are composites; a topological sort decides the order they run in.
- cyclomatic complexity
- McCabe, 1976. The number of linearly independent paths through a function – in practice 1 + the number of decision points. The metric people actually argue about in review.
- decision point
- A construct that forks control flow: if, elif, case, for, while, do, catch/except, && , || and the ternary ?. Each one adds one path, and one to the complexity.
- deferred call resolution
- Call sites sit in a buffer until every file has been parsed, then match against the completed definition map. Without it, every forward reference silently disappears.
- ForceAtlas2
- Force-directed layout that settles the graph: edges pull, nodes repel. Tightly coupled functions end up next to each other because nothing pushes them apart.
- GraalVM native image
- Ahead-of-time compiled binary of the Kotlin analyser. Starts in milliseconds and runs on machines with no JVM installed.
- Halstead metrics
- Maurice Halstead, 1977. Count distinct and total operators and operands (n1, n2, N1, N2), then derive volume, difficulty, effort, time and estimated bug count from those four numbers.
- keep-rules
- The per-language whitelist of ANTLR rule names we keep when shrinking the parse tree (32 for C, 55 for Java, 33 for Python). Everything else is deleted; children move up. Some rules are conditional – keep postfixExpression only when it holds a call.
- metric description
- The name, the prose, and the Good / Too low / Too high thresholds, shipped as data ahead of the values. The UI recolours from these without re-running any analysis.
- module anchor
- A hidden node per file or class, joined to its members by hidden edges, so the layout pulls a module into one blob without a separate clustering pass.
- NDJSON
- One JSON object per line on stdout. The extension forwards lines into the webview as they arrive, so nodes appear while analysis is still running.
- operators and operands
- Halstead's split of a program's tokens: operators do things (+, =, &&, ?), operands are what they do it to (identifiers, literals). We define both as token-type sets per language.
- out-degree
- How many distinct callees a function reaches – the number of outgoing call edges from its node. High out-degree marks an orchestrator; it does not mean the function is complex by itself.
- reduced parse tree
- The ANTLR tree shrunk by a per-language whitelist of keep-rules. Nodes not on the list are deleted and their children are spliced into the parent.
- scaled value
- A raw metric mapped into 0…1 against its thresholds – 1 at the good value, 0 at the bad one, linear in between. Colour is a function of this, never of the raw number.
- Sigma.js
- WebGL graph renderer. Keeps thousands of nodes interactive in a webview and hosts our custom module-contour shader.
- uniqueName
- The lookup key for a function: package.Type.method in Java, the bare identifier in C and Python. Every collision in the graph starts here.
Introduction
You have inherited the Python backend of an LLM-serving app: 16 files, 2,454 lines – FastAPI layer, generation engine, model host, storage, and collector classes that hook into a transformer while it runs. Your job is to learn the codebase and start extending one of the modules.
All of that looks like a lot to learn and memorize. Code ReViewer offers an alternative to reading it top to bottom and building the map in your head. Right-click backend/ folder, hit Analyze with CRV, and a second later every function becomes a graph node, coloured by one of the chosen metrics. That run – 2,454 lines in 1.0 s – emits 245 lines of NDJSON: 14 metric descriptions, 33 modules, 78 functions, 19 extra definition locations, 101 call edges.
Code ReViewer is a VS Code / Cursor extension, built as our engineering thesis at Gdańsk University of Technology with Gabriel Myszkier and Jakub Stachowicz. This project is After our defence in February 2027 it goes open source. For now, the extension is available in closed beta for interested parties, and documentation is live at gre-v-el.github.io/crv-docs.
Usage of an extension - navigate the call graph, select metrics, adjust histogram ranges, and jump directly to definitions.
Reviewing whole Python backend module
The codebase under review is a real repository: the LLM-monitoring backend, shown in the screenshots below.
What are the global code metrics?
Tick Lines of Code and cyclomatic complexity. The graph recolours along a green-to-red gradient, and the histogram displays the full distribution rather than a misleading average. This package comes back mostly green: 74 of 78 functions sit at or under complexity 10, with a median of 2.5, a median length of 9 lines, and nothing above 25. The results are instant, showing that the overall codebase is in solid shape.
What is the complexity?
You can sort the function browser by chosen metirc:
| Function | File | Complexity | Lines |
|---|---|---|---|
_run | engine.py:213 | 18 | 146 |
collect | collectors/attention.py:65 | 13 | 71 |
generate | api.py:193 | 12 | 73 |
aggregate | benchmarks.py:105 | 11 | 18 |
The last row is the standout: 18 lines, complexity 11 – a dense pile of comprehensions and conditionals where nearly every line branches. Sorting by size never surfaces it, and a reviewer skimming the diff would likely nod it through. That is exactly what this metric is for.
What does the chosen function touch?
Click _run: 22 outgoing edges, the highest out-degree here, reaching the model host, the collectors, the deep-capture path, and the token decoder. One function orchestrates the whole generation loop – defensible for an engine, but now a conscious, visible decision rather than an accidental drift.
_run selected: 146 lines, complexity 18, the full Halstead stack
underneath. The star around the node is its 22 outgoing calls.
Where do the module boundaries run?
Switch the display mode to Modules and enable contours: the 33 modules represent 16 files plus 17 classes – Engine, ModelHost, Storage, DeepCapture, and the collectors. Any coupling that crosses a contour highlights exactly what will hurt when the collector interface changes.
And what the graph refuses to tell you?
97 definitions collapsed into 78 nodes. Because Python keys on the bare identifier, methods like __init__ (six definitions) and collect (one per collector) are each merged into a single node with multiple Go to definition buttons. While convenient for quick navigation, it is technically imprecise – callers of different implementations are pooled onto a single vertex.
Architectural Trade-offs: Name Matching Over Full Type Checking
Code ReViewer relies on identifier matching rather than full semantic analysis: there is no symbol table, type inference, or compilation step.
Building a precise resolver is a major multi-year effort per language. To support three languages within the scope of a single thesis, we chose a heuristic approach. This trade-off allowed us to prioritize the visualization pipeline, metric calculations, and user interactions.
The parsing pipeline:
- Parse each source file using ANTLR with community-maintained
.g4grammars. - Filter the parse tree using per-language rule definitions.
- Traverse the AST with a listener to extract modules, functions, and metrics.
- Collect all call sites into an in-memory buffer without immediate resolution.
- Resolve calls in a final pass after all files are parsed, matching valid calls to target definitions and generating graph edges.
Filtering: throwing away most of the tree on purpose
An ANTLR tree is mostly ceremony – a single expression descends through a dozen precedence rules before reaching the identifier you care about. So we run a reduced parse tree pass first: each language declares a flat list of keep-rules (32 for C, 55 for Java, 33 for Python), anything else is deleted and its children spliced into its parent. Rules can be conditional – keep postfixExpression, but only when it holds a ( (a call) rather than a ++.
Resolving: how a node and an edge get made
Nodes come from definitions. On entering a function-like construct the listener computes that subtree's metrics and calls addFunction(name, uniqueName, metrics, startLine). The uniqueName is the key everything hangs off:
| Language | uniqueName | Consequence |
|---|---|---|
| Java | com.app.Repo.load (package + type + method) | Overloads collapse into one node with several definitions |
| C | opj_j2k_read_siz (bare identifier) | Same-named statics in different files merge |
| Python | collect (bare identifier) | Every collector's collect merges |
A new key emits a node; a seen key appends a source location instead, which is why merged nodes show Go to definition 1 and 2. Modules are tracked with a stack, not "the last module I saw" – otherwise leaving a Python class attaches the next top-level function to the class you just left, and colours, contours and clustering all quietly lie.
Edges come from call sites, but not yet. A call site looks nothing up: it builds an ordered candidate list of names the callee might have and buffers it with the caller's key. In Java the list is genuinely inferred – receiver's declared type, then this type, superclass, interfaces, static imports – so the analyser tracks parameters and locals as name → resolved type. C and Python get a one-element list: the identifier itself.
deferred call resolution is why it works. Only after every file is walked does flushCalls() try each candidate against the map of all definitions:
for (const candidate of calleeUniqueNameCandidates) {
callee = this.allFunctions.get(candidate);
if (callee) break; // first hit wins; later candidates are never tried
}
if (callee) {
this.emit(call({ callerId: caller.id, calleeId: callee.id }));
}Resolve at the call site instead and every forward reference and cross-file call vanishes.
The resolver in one picture. Hit → edge; miss on every candidate → nothing.
Silence as a design decision
A call to torch.no_grad() or memcpy produces no edge – and no node, no placeholder, no warning:
| Input | Lines | Call sites | Edges drawn | Resolved |
|---|---|---|---|---|
LLM-monitoring backend/ (16 files) | 2,454 | 572 | 101 | 17.7% |
pybottle.py | 4,583 | 1,310 | 506 | 38.6% |
j2k.c | 13,180 | 1,616 | 149 | 9.2% |
The backend discards 471 of 572 call sites, all into torch, transformers, fastapi or the standard library; drawing them would bury 78 real functions under hundreds of library leaves. So the graph shows what it can prove by name and stays quiet about the rest – only ever as complete as the file set you hand it, and an honest sparse picture beats a dishonest dense one.
The metrics: what they are, and how we count them
Cyclomatic complexity, and why it lives in the parse tree
McCabe's 1976 cyclomatic complexity counts the linearly independent paths through a function. Calculated formally as over its control-flow graph. For structured code it collapses to:
A function with complexity 10 needs at least 10 test cases to cover every path. All the engineering is in determining which nodes count as a decision point:
calculateMetric(fn: ParserRuleContext): number {
return 1 + countOccurrences(fn, this.decisionTokens, this.decisionRules);
}countOccurrences counts two things: terminal tokens whose type is in one set, and parser rules whose index is in another. That split keeps the metric honest across languages instead of degenerating into keyword grepping:
- Java counts tokens:
if,for,while,do,case,&&,||,?. Note the omissions –elseanddefaultadd no path, they are the fall-through. - Python counts
if,elif,for,while,and,oras tokens, plusexcept_clauseas a rule, since matching the token would also catch thetry. - C counts
case,&&,||,?as tokens but takesselectionStatementanditerationStatementas rules: oneif … elseis a single node contributing exactly one decision – the right answer, and one a token-counter has to special-case its way to.
Halstead: measuring a function as a vocabulary problem
Halstead metrics (1977) start from a different intuition: a program is a piece of language, so measure its vocabulary and length. Everything derives from four counts of operators and operands – distinct operators, distinct operands, and their totals.
We get them by classifying token types per language into two explicit sets, then running two counters over the tree: one sums hits, the other collects matched token text into a Set. Explicit sets rather than "everything that is not an operand" – grammars are full of tokens (;, ), ,) that are neither, and counting punctuation as an operator inflates every number below.
From those four, eight composite metric values follow:
Volume is information content in bits; difficulty rises when few operators are reused hard over few distinct operands; effort is their product, time divides it by Halstead's constant of 18 mental discriminations per second, and bugs is his defect estimate. For the two functions the walkthrough flagged:
| Metric | _run (engine.py) | aggregate (benchmarks.py) |
|---|---|---|
| Lines of Code | 146 | 18 |
| Cyclomatic complexity | 18 | 11 |
| / | 7 / 136 | 8 / 26 |
| / | 76 / 342 | 17 / 65 |
| Volume | 2,993 | 417 |
| Difficulty | 8.80 | 10.00 |
| Effort | 26,341 | 4,172 |
| Time | 1,463 s (≈ 24 min) | 232 s |
| Bugs | 0.295 | 0.09 |
Read the columns against each other and the metrics stop being interchangeable. _run is eight times the size and seven times the volume; its 136 distinct operands say it touches a lot of things. aggregate is 18 lines and scores higher difficulty, working a handful of operators over a tiny operand set. Volume finds the big function, difficulty the hard one, and a reviewer watching line counts sees only one of the two. Do we believe aggregate holds 0.09 bugs? No – Halstead's constants were fitted to 1970s programs. The ordering within one codebase is the only claim the UI makes.
The eight derived values are not hardcoded in an order: each composite calculator declares its dependencies, and a Kahn topological sort at construction settles the sequence. That matters because the base calculator set is per language – if a future grammar cannot produce , everything downstream drops out of the output automatically and the frontend never sees a metric it cannot trust.
Every judgement call lives in the webview
The backend emits fourteen numbers and refuses to say which are bad. Ahead of them it emits a metric description per metric: name, hover text, and default thresholds (complexity good ≤ 10, bad ≥ 25; lines of code good ≤ 20, bad ≥ 50) – shipped as data, not baked into the values. The webview maps each raw value to a scaled value in 0…1, 1 at the good threshold and 0 at the bad one:
if (value <= good && low !== null) {
return value <= low ? 0 : (value - low) / (good - low);
}
if (value >= good && high !== null) {
return value >= high ? 0 : (high - value) / (high - good);
}
return 1;Colour is one hue rotation over that score – hsl(120 × score, 100%, 50%), red at 0, green at 1. Several metrics at once are combined by a join function you pick: multiply (one bad metric drags the node red) or average (a node must be broadly bad to look bad). Because the raw values already sit in the browser, dragging a threshold recolours the graph without touching the backend – re-running analysis to ask "what if complexity 15 is fine?" would make the question not worth asking.
Rendering: surviving a thousand nodes in a webview
The graph tab is Sigma.js + graphology, and three decisions keep it usable.
WebGL with a custom node program. @sigma/node-border draws each node twice: an outer ring for module colour, a fill for metric colour – two facts, one glyph, no legend. Module hues are spaced by the golden angle (222.49°), so neighbours are never two shades of the same blue.
ForceAtlas2 in a worker. LinLog mode, strong gravity, Barnes-Hut, off the main thread, auto-stopping after ten idle seconds.
Clustering for free, viamodule anchor No clustering pass: each module gets a hidden node and each function a hidden edge to it, so the layout pulls modules into blobs by itself and clicking an anchor zooms to its members. Contours on top are a custom fragment shader, switched off above 300 nodes where the blobs merge into one sheet.
Because the transport is NDJSON, nodes appear while analysis still runs: the host splits stdout on newlines, the webview switches on a one-character tag (MD, M, F, L, C) and mutates the graph in place. A repeated call thickens an existing edge instead of adding a parallel one, and that thickness feeds the layout as weight.
Two engines, one wire format
Kotlin came first – ANTLR's home turf is the JVM. Then we wrote the analyser again in TypeScript, for one reason: deployment. A .jar needs java on PATH, a GraalVM native image is an unsigned binary, and managed corporate laptops block both – and a tool nobody at work may run has no users. The TypeScript backend is forked onto the Node runtime the extension host already runs under, so it needs nothing VS Code did not bring.
codereviewer.backend | Artifact | Requires |
|---|---|---|
node (default) | reviewer.js | nothing |
jar | reviewer.jar | java on PATH |
native | reviewer-<platform> | a GraalVM build |
The unexpected benefit was verification. Two implementations sharing only the grammars and the protocol are a differential test of both: Kotlin regenerates golden NDJSON over shared fixtures, and the TypeScript suite asserts that every description, module, function, metric value, location and call edge matches – everything except emission order, which the webview treats as unordered anyway. Currently 123 tests across 20 files. Writing the engine twice is also how we found out the protocol was real: every disagreement marked a spec that had been living in one team's head.
Where the graph stops being trustworthy
Name collisions in C and Python. 97 definitions became 78 nodes above, and the four collectors' collect methods are one vertex – an edge into it names the interface, not the implementation. Java escapes this only because its keys carry package and type.
Dynamic dispatch. Function pointers, getattr, runtime virtual calls – no name to key on, no edge. The graph under-reports exactly the code hardest to reason about.
One language per selection. Mix extensions and the backend refuses to run; cross-language calls do not exist in this model.
Halstead's absolute numbers. Ranking inside one codebase: useful. "This function contains 3.34 bugs": not a claim we make.
Scale. A few hundred nodes is comfortable, contours stop at 300, and a force layout over a monorepo is a hairball no colour rescues.
What comes next
Three languages, fourteen metrics, one protocol, three backends – and the remaining work is additive. User-defined metrics come next: a metric is already a declarative description plus a calculator, so the work is exposing that seam. More grammars are close to free, which was the point of paying the ANTLR tax up front.
Working on something like this?
If any of this is close to a problem on your team, I would like to hear about it. LinkedIn is the fastest way to reach me.