Engine · Technology
How the CodeLaser engine works underneath.
Restructuring a large Java codebase touches thousands of places. Every one of them has to be right. A syntax tree records what the code says. It does not say what a name refers to, what a call can reach, which objects a method modifies, or what a single edit affects downstream. This page describes the layers of the CodeLaser model that answer those questions.
The stack
The engine is built in four layers.
The two lower layers are maddi, CodeLaser’s open-source analyzer. The two upper layers are the commercial engine. Each layer works on the complete output of the one beneath it. Pricing belongs to the structure layer and gets a section of its own below.
Front end
Every name is resolved before anything is changed.
Renaming a method looks like a text operation. It is a question about the type system: which declarations are the same method, and which calls can reach them. The front end answers it the way a compiler does, for every name in every file, before any operation runs.
acceptRenaming
Sink.accept has to rename Ledger.accept, which implements it, and the method
reference that points at it. It must leave Audit.accept alone. The text is identical in
all three places. What separates them is each receiver’s type, the generic type argument, and the
method the compiler would select.
interface Sink<T> { void accept(T value); } class Ledger implements Sink<Entry> { public void accept(Entry e) { … } // implements Sink.accept; javac adds a bridge accept(Object) } class Audit { void accept(Object o) { … } // same name, same erased signature, no relation } void wire(List<Entry> entries, Ledger ledger, Audit audit) { entries.forEach(ledger::accept); // target type Consumer<? super Entry>: Ledger.accept audit.accept(entries); // Audit.accept: outside the rename }
A build is more than one set of sources. Main code, tests and the code that annotation processors generate at build time are compiled separately, and a large project has dozens of such source sets across its modules. The CodeLaser model covers all of them, generated code included: a generated class refers to the same types a written one does, and can sit in the same loops.
Across those source sets the same fully qualified name can be declared more than once. The engine therefore identifies every type by its source set as well as its name. An operation given a name that matches more than one declaration is refused, and the refusal lists the candidates.
The classpath is part of the CodeLaser model. A call into a library resolves to a real declaration, read from the library’s bytecode. For the JDK, what each method does to its arguments comes from pre-analysed descriptions of its packages, one set per JDK version.
Analysis
What each method does to the objects it touches.
maddi computes, for every method in the program, which objects it modifies, and whether what it returns is still linked to the object it was called on. For every type it works out how close to immutable the type is, and what stops it being fully immutable. Nothing has to be annotated by hand.
Both return a
List<String>. Through the first, a caller can change the Order. Through
the second, it cannot. The difference lies in what List.copyOf does, and the analysis
takes that from the pre-analysed JDK.
class Order { private final List<String> tags = new ArrayList<>(); List<String> tags() { return tags; } List<String> snapshot() { return List.copyOf(tags); } void tag(String t) { tags.add(t); } } → tags() its result is linked to a field: a caller can modify this Order→ snapshot() its result is independent of the Order→ tag(…) modifies this Order→ Order mutable, through tag(…) and through what tags() gives out
The lines under the code say in words what the analysis concludes. maddi records the same
facts as annotations such as @Modified and @Independent.
The facts depend on each other.
Whether a method modifies its argument depends on the methods it calls, and those may call it back. The analysis works through these cycles until nothing changes.
Per statement.
For every statement the analysis records which variables are read, assigned and modified. Cutting a long method in two needs exactly that: the values that flow into the second part become its parameters, and the values it produces become what it returns.
Why a refactoring engine needs it.
Moving a member, splitting a class and extracting a method all move state. Whether such a move is safe depends on who can still get at that state afterwards. Those are the facts this layer supplies.
Structure
One graph for the whole program and its build.
Above the analysis sits CodeLaser’s own model: every type and member, every reference between them, and the build units they belong to. The build is part of the graph. A module descriptor, a service registration or a resource loaded by a relative path can block a change as surely as a method call can.
Loops.
Types that all reach each other cannot be separated into modules built in a fixed order. The graph finds every such loop and ranks them by size. In Apache Ignite’s core the largest held 2,476 classes.
Reachability.
Dead code is what nothing can reach from the program’s entry points. A call through an interface can arrive at any implementation, so reachability has to follow overriding as well as calls, across every module at once.
Boundaries.
For any proposed boundary the graph reports which references cross it and in which direction. It also reports what else stands in the way: packages split across units, service registrations, module directives, resources.
Precision matters at the level of a single reference. When Apache Ignite’s largest utility class was split in two, one method that stayed behind contained an anonymous class, and that anonymous class called a method that had moved. That one reference was enough to pull about 1,500 classes back into the loop. The graph named it, down to the anonymous class.
Pricing
Every change is priced by trying it on the graph first.
Before a change is made, CodeLaser works out what it buys and what it costs. What it costs is always the same kind of thing: the number of places in the code that have to be edited. What it buys depends on the goal: classes that leave a loop, references that no longer cross a boundary, code that can be deleted. That is what pricing a change means here. Loops are the example on this page because they are the most complex case and show the principle best.
The quick way to price a change to a loop is to pretend one class is gone and count how much of the loop falls apart. A real change leaves a class that is still in use where it is. It changes who uses what. So CodeLaser prices each kind of change by making that change to the graph, and then working out the loops again. The figure shows the same four classes in one loop, first as they are and then priced three ways.
How to read it. Each circle is a class. An arrow from A to X means that code in A uses X: it calls it, creates it or names its type. A loop is a group of classes that can all reach each other by following the arrows. Classes outlined in blue are in a loop. A red arrow is a use that has to be removed by editing the code. In the first panel, grey dashed arrows are left out of the graph. In the third, blue arrows are new.
The quick method is the simplest one, and it is usually far too optimistic. Pricing that models the change itself takes longer and comes much closer to what the change delivers. CodeLaser uses it for every candidate before anything is edited.
Some changes pay off only when they are complete. In Apache Ignite, 985 uses ran from one part of the core into Ignite’s cache engine. By that stage of the work the largest loop held 1,058 classes. Removing the uses one at a time barely changed it. It fell to 558 only when the last three were gone. A change of that size can be planned only if all 985 are priced together, before the first edit.
Operations
One operation makes all the edits a change requires.
An operation is not a list of edits. It is a request, and the edits are derived from the CodeLaser model: every reference that names what changes, in every file, of every kind. Nothing is written until all of them are known and every precondition holds.
What a move accounts for.
Imports and static imports. Qualified references. Subclasses and overrides in other modules. Signatures and generic bounds. Documentation links. Module directives: exports, opens, uses, provides. Service registration files. Resources loaded relative to a class. Class names written as strings, rewritten where the method that reads them has been declared to the engine, reported where it has not.
At the scale of a codebase.
One operation in the Timefold work moved 27 classes and made 5,603 edits to keep the project compiling. Another operation made 23,642 edits across 946 types. In Apache Ignite, moving 63 utility members rewrote 420 call sites in 207 files.
Modelling implicit references.
A subclass can read a protected field of its supertype without naming the class that declares it. A text search for that class finds nothing. The graph holds the reference, so removing the field is refused, with both sides named. Class names written as strings are the same kind of reference, and get their own section below.
From the Timefold work. extractCompanion moves an
interface’s static factory methods, the ones that return new Impl(…), into a companion class, so
the interface no longer depends on its implementations. Asked to do this for a type whose static methods
name no implementation, it declined before touching a file, and said why.
RefactorConflictError: No static method names a subtype of it,
so there is no companion to extractClass names in strings
Class names written as strings are references too.
Java does not see them. To the compiler a string is text, so renaming the class it names, or deleting a method that is only reached through it, compiles cleanly and fails at run time. Reflection, service loaders and serialization all work this way.
The engine finds these names through the methods that read them. You
declare those methods, Class.forName or a framework’s own loader, and it finds every call to them
itself. From then on the name inside the string is a reference in the graph. A rename rewrites the string. A
delete that would leave it pointing at nothing is refused.
What two operations do with the name once the call that reads it is declared.
Class.forName("com.example.orders.OrderStore"); // declared: this call reads a class name → rename the string is rewritten along with the class→ delete refused: this string still refers to the class
What is still outside the CodeLaser model stays there: a name assembled from pieces at run time, a lookup that was never declared, a framework that finds a class by scanning for annotations. Nothing in the source connects the two ends, and no static analysis can see the link, CodeLaser’s included. What the engine can do there is predict where such links will break. In our work on Timefold Solver it predicted 151 failing tests before part of the code moved into a module of its own. 151 failed. The test suite is still the final check on any change.
Origins
Built against real codebases since 2019.
Java has overloading, generics with erasure, type inference, lambdas and method references, inner and anonymous classes, records, sealed types and annotation processing. Real codebases use all of them at once. Most of the work since then went into cases like the ones on this page. Each new codebase turned up constructs the engine did not yet handle. We extended the engine before going on.
Codebases the engine has worked on include Elasticsearch, OpenSearch, Trino, Apache Pulsar, Apache Ignite, Jenkins, QuestDB, Caffeine and Timefold Solver. The analyzer is run against, among others, Guava, Apache Camel, ActiveMQ, LangChain4j and Fernflower, and against one closed-source codebase of three million lines.
See it run on your code.
The page on running CodeLaser shows what a plan written against these operations looks like, and the evidence pages show what they produced on open-source codebases.
