DEV Community

Braham
Braham

Posted on Originally published at braxik.com

What I gave up to run Spring static analysis in a browser

There is a question that costs me half an hour every time I have to answer it:

If I change this controller method, what else breaks?

The honest process was always the same. Find Usages on the handler. Follow the
service call. Find Usages again. Open the repository, squint at a derived query
name, guess which table it lands on. Three greps for a method name that turns
out to be declared in eleven places. Then make the change anyway, because half
an hour is what the estimate had left in it.

I wanted the machine to do that walk. What I did not want was to upload a codebase I
did not own to somebody else's analysis service to get it — not out of
principle, but because "we send your code to a third party" is the sentence
that ends the conversation before it starts. So I set myself a constraint:

The analysis runs in the browser. There is no server. There is nothing to
upload to.

This post is about what that constraint costs, because it turned out to cost
much more than a bit of bundle size, and the interesting parts of the tool are
all consequences of paying it.

The thing you lose is types

A Java static analyser normally starts by not being alone. javac will hand
you a resolved AST. The classpath tells you what org.springframework.data.
repository.CrudRepository
actually declares. Your jars are on disk. When you
see repo.save(owner), you can ask the compiler what repo is and get a real
answer.

In a browser tab you have none of that. No JDK. No Maven cache. No classpath.
You have the files the user picked with the File System Access API, and a
parser you can compile to WebAssembly. I used
tree-sitter with the Java grammar,
which gives you a fast, error-tolerant concrete syntax tree.

What tree-sitter gives you is syntax. What it does not give you is types.

That distinction sounds academic until you look at a call site:

public Owner findOwner(Integer id) {
    return repository.findById(id).orElseThrow();
}
Enter fullscreen mode Exit fullscreen mode

The syntax tree tells you: there is a method invocation named findById, on an
identifier named repository, and another named orElseThrow on whatever the
first one returned. It does not tell you what repository is. And in a project
with OwnerRepository, PetRepository and VetRepository, findById is
declared three times.

So the first thing I had to accept is that every edge in the call graph is a
candidate, not a fact.

The resolution ladder

You are not completely blind, though. Java writes a great deal of type
information down in the source, and if you are willing to read it in the same
order the compiler would, you can resolve most receivers without a type checker.

The ladder, innermost declaration wins:

@Service
public class OwnerService {

    private final OwnerRepository repository;   // 4. field declaration

    public Owner rename(Integer id, String name) {
        var owner = repository.findById(id);    // 3. local var — but `var`
                                                //    hides the type again
        OwnerRepository r = this.repository;    // 2. typed local
        return r.save(owner);
    }

    public void audit(AuditLog log) { }         // 1. parameter declaration
}
Enter fullscreen mode Exit fullscreen mode

Parameters, typed locals, constructor-injected dependencies and plain fields all
state what they are. Read them in Java's scoping order and repository.save(...)
resolves to exactly one class. That covers the overwhelming majority of Spring
service code, because Spring code is mostly constructor injection and typed
fields — the framework's own conventions are doing me a favour here.

What defeats it:

  • var, which is a declaration that declines to declare anything
  • chained calls, where findById(id).orElseThrow() needs the return type of the first call to resolve the second — that is inference, not reading
  • anything where the receiver is an expression rather than a name

Confidence instead of booleans

The design decision that everything else hangs off: I do not report an edge as
present or absent. I report how it was matched.

/*
 *   "exact"  — the callee name is unique across the whole project
 *   "likely" — the caller's class declares a dependency whose type owns a
 *              method of that name
 *   "weak"   — name match only, several classes declare it
 */
Enter fullscreen mode Exit fullscreen mode

exact is a gift: if only one class in the entire project declares
recalculateSettlementWindow, then a call to that name is that method, no type
resolution required. In real codebases a surprising share of your domain method
names are globally unique, because people name things after what they do.

likely is the ladder above. weak is a name match and nothing more.

The reason this matters is stated in a comment I wrote early and have not
needed to change:

Presenting a weak edge as fact is how a tool starts confidently lying about an
architecture, which is worse than not answering.

A tool that says "this endpoint touches owners and pets" and is wrong 15% of
the time is not 85% useful. It is useless, because you have to verify every
claim, and verifying takes as long as the original half hour.

When a guess stops being a guess

Weak edges are still worth showing — sometimes. save() matching three
repositories is a guess a reader can resolve instantly, because they know the
code and you do not. Show them the three.

toString() matching forty model classes is not that. From the source:

/*
 * `save()` matching three repositories is a guess worth showing: one of the
 * three is right, and a reader who knows the code can pick it. `toString()`
 * matching forty model classes is not a guess, it is noise wearing a guess's
 * clothes — the edge asserts a relationship that exists between the caller
 * and NONE of the forty, because what the caller actually invoked was
 * `toString` on whatever object happened to be in scope.
 */
Enter fullscreen mode Exit fullscreen mode

So there is a cap. Above N same-named candidates with no preferred class, the
edge is dropped rather than recorded weakly. This is not only about display
noise: each recorded edge is a node the trace walk will spend budget expanding,
and forty junk edges at depth two is how a real flow gets crowded out of a
result by toString.

The bug that taught me the most

Here is the one that changed how I think about the whole category.

The shape, which I have now hit more than once:

GET /reports/rebuild                badged "no database operations"
  → ReportServiceImpl.rebuildAll
      new SegmentRebuildTask(segmentDao, ...)
      executorService.invokeAll(tasks)
  → SegmentRebuildTask.call()
      segmentDao.updateRollup(...)         TWO UPDATES
      segmentDao.clearStale(...)
Enter fullscreen mode Exit fullscreen mode

(Names changed throughout. The shape is what matters, and the shape is
exactly this.)

The executor invokes call(). Nothing in the source calls call(). There is no
edge to follow, so the walk finished, found no database operations on any path
it could see, and reported — confidently — that this endpoint touches no
database.

It runs two UPDATEs.

This is the worst thing an analysis tool can do. Not "missed something" — a
definitive negative that is false. A missing finding costs you the tool's
value. A false absence costs you production, because you shipped the change
believing you had checked.

The fix is not to guess which Callable was submitted. Walking a body I picked
by inference would be a fabricated finding, and a fabricated finding is worse
than an admitted gap — it has the same shape as a real one and you cannot tell
them apart. The fix is vocabulary. The walk already distinguished:

  • inspected — I read this body
  • framework terminal — I reached this and did not inspect it, and nothing of yours is behind it (Spring Data's generated save, a JDK call)
  • unavailable — I reached this and could not read it

An executor handoff is the third kind. So:

if (readable && EXECUTOR_HANDOFF.test(sliced)) {
  frameworkTerminals.push(
    `${name} (work handed to an executor — its task's body is not called ` +
    `from anywhere this walk can follow)`
  );
}
Enter fullscreen mode Exit fullscreen mode

An uninspected boundary on the path blocks the completeness claim, so the badge
flips from "no database operations" to "not established". That sentence
is true, and the difference between it and the false one is the difference
between a tool you can act on and one you cannot.

I checked six repositories for this shape before shipping the change. The
pattern is real and it is not rare. @Async and CompletableFuture.supplyAsync
have exactly the same structure.

The safety net I did not know I had

A second story from the same week, and this one is a warning about measurement.

There was a cap on edges per node, set to 40, put there originally as a guard
against pathological graphs. Then I measured it: fan-in on one corpus peaked at
exactly 40, with the ninetieth percentile also at 40. When your cap and your
p90 are the same number, the cap is not a safety margin — it is the shape of
your data. One node in five was losing inbound edges before any walk started.

So I raised it. And the raise produced 49 new false "no database operations"
on that corpus.

The cap had been the only thing preventing them, entirely by accident. With
fewer edges recorded, the walk hit its limits sooner, and hitting limits made it
say "not established" — the honest answer, arrived at for a wrong reason. Raise
the cap, and the walk could now reach the end of more paths and declare them
clean, when the real reason they looked clean was that the mapper at the end
was a declaration with no body.

The raise is safe now, but only because the coverage rules landed with it: an
abstract mapper declaration is a framework terminal, not an inspected body. There
is a script that checks this — verify-negatives.mjs, six fixtures, must read
zero — and a comment saying that if the coverage rules are ever reverted, the
cap must be reverted with them.

The lesson I actually took: when a defensive limit is load-bearing, you find
out by removing it, and what you find out is a count of the lies it was
suppressing.

What Spring does that your code does not say

One more that has nothing to do with types. Consider:

@Controller
class OwnerController {
    @ModelAttribute("owner")
    public Owner findOwner(@PathVariable Integer id) {
        return owners.findById(id);          // a SELECT
    }

    @GetMapping("/owners/{id}/edit")
    public String edit(Owner owner) {         // no database call here
        return "owners/edit";
    }
}
Enter fullscreen mode Exit fullscreen mode

Spring runs @ModelAttribute and @InitBinder methods before every request
into that controller. Nothing in the handler calls them. A walk that starts at
the handler reports zero database access for an endpoint that performs a SELECT.

There is no clever general solution. You encode the framework's lifecycle,
explicitly, and label those steps with the annotation that causes them to run so
the reader knows why a method they never called is in their trace. Framework
knowledge is not a shortcut around analysis — it is the analysis, for a
framework-shaped codebase.

What it still cannot do

Because the honest list is the useful part of any post like this:

Chained receivers. repository.findById(id).get() needs the first call's
return type. That is inference. Those stay unproven.

MyBatis and jOOQ. Table names are read from Spring Data JPA repositories and
JPA entities. A mapper's implementation is generated from XML or a build step, so
there is no body on disk to read. On a mapper-based codebase the tables list
comes up mostly empty — I measured a median of zero tables per endpoint on one.
The output says unknown; a reader who mistakes that for "none" has been misled,
which is why it is now stated on the page rather than discovered.

Branches and loops. The trace shows the code's shape, not an execution. A
call inside an if appears whether or not that branch runs, and a loop body
appears once rather than N times — which is exactly the difference between one
round trip and a hundred.

@Transactional self-invocation. this.doTransactionalThing() bypasses
Spring's proxy at runtime. The trace shows a boundary that would not actually
apply.

Was the constraint worth it

Everything above is a cost of not having a compiler. A server-side analyser with
javac on the classpath has none of these problems, and would give better
answers.

It would also never run on most of the codebases that need it, because those
belong to companies with a procurement process, and "it runs in your tab, open
the network panel and check" is a claim anyone can verify in ten seconds while a
security questionnaire takes six weeks.

That is the whole trade. Weaker analysis that runs on your real code beats
stronger analysis that runs on code you are allowed to upload.

The part I would defend hardest, though, is not the browser bit. It is that
being unable to resolve types forced me to build a vocabulary for uncertainty
before I built anything else — exact, likely, weak; inspected, reached,
unavailable — and that vocabulary is the reason the tool can say "I did not
manage to look everywhere" instead of quietly reporting an absence it never
earned.

A compiler would have let me skip that. I am not sure the result would have been
better.


The tool is Braxik. It traces a Spring
Boot endpoint from controller to database, in your browser, with the file and
line behind the claims. There is a pre-loaded trace of spring-petclinic on the
page if you want to see the output without having a repo to hand.

Top comments (0)