Turn a code diff into an architectural diagram.
A line-wise diff tells you which characters changed. It does not tell you that a change moved a dependency across a package boundary, or introduced a cycle, or coupled two modules that used to be independent. Reviewers reconstruct that from memory, one file at a time.
Give striff-lib two versions of a codebase and it gives you back a "striff": a diagram of the classes and relationships the change actually touched, with added, deleted, and modified components marked. Nothing else is drawn, so the picture stays small enough to read during a review.
Every striff is a PlantUML class diagram. striff-lib builds the PlantUML model from your code and renders it with PlantUML's MIT-licensed build, so the output follows PlantUML's conventions, and the decorators below can inject any PlantUML (notes, legends, skinparams) into it.
Requirements: Java 17 and Maven 3.x. No native dependencies. Diagrams render through PlantUML's pure-Java Smetana layout engine by default, so Graphviz is not required (install it only if you opt into LayoutEngine.GRAPHVIZ).
Add the dependency (check the badge above for the latest version):
<dependency>
<groupId>io.github.hadi-technology</groupId>
<artifactId>striff-lib</artifactId>
<version>5.0.1</version>
</dependency>Or build from source:
mvn clean package assembly:singleMinimal example (see StriffAPITest for more):
import com.hadi.clarpse.compiler.ProjectFiles;
import com.hadi.striff.StriffConfig;
import com.hadi.striff.StriffOperation;
import com.hadi.striff.diagram.StriffDiagram;
import java.util.List;
ProjectFiles oldFiles = new ProjectFiles("/path/to/original/code");
ProjectFiles newFiles = new ProjectFiles("/path/to/modified/code");
List<StriffDiagram> striffs = new StriffOperation(
oldFiles, newFiles, new StriffConfig()).result().diagrams();
for (StriffDiagram diagram : striffs) {
String svg = diagram.svg(); // null if metadata-only
int size = diagram.size(); // number of components
var pkgs = diagram.containedPkgs(); // packages included
var components = diagram.cmps(); // DiagramComponent set
var relations = diagram.relations(); // RelationsMap
var changeSet = diagram.changeSet(); // ChangeSet for this diff
}Striff uses the Clarpse parser under the hood to build the source model from your codebase.
By default every language Clarpse supports is enabled; narrow that with StriffConfig.setLanguages(...).
Supported languages (via Clarpse):
- Java
- C#
- TypeScript (requires Node.js and a valid
tsconfig.json) - Python (requires Node.js)
Parsing failures (e.g., unsupported syntax) are reported by Clarpse and surfaced through Striff as compile warnings on the output. Striff will still attempt to return diagrams/metadata when possible.
Interrupting the thread that constructs a StriffOperation cancels it, so a caller can enforce a
time budget with Thread.interrupt(). The interrupt reaches the threads parsing each revision as
well as the merge and relationship-extraction phases, and the constructor throws a
java.util.concurrent.CancellationException with the interrupt flag still set. Cancellation is
cooperative: it takes effect at the next checkpoint in Clarpse or Striff, so it is prompt rather
than instantaneous.
Limit parsing and diagram generation to a specific file list:
StriffConfig config = new StriffConfig()
.setFilesFilter(List.of("/src/main/java/com/acme/Foo.java"));Note: the file filter is applied to parsing, so only the filtered files are compiled and considered for diagram components.
With a filter, an ordinary analysis compiles only the filter's files: a reference that leaves them
has nothing on the other end. A one-level analysis also models the repository files the
filter's files reference, without compiling the rest of the repository. It is built on Clarpse's
one-level analysis (see Clarpse's docs/one-level-analysis.md).
StriffConfig config = new StriffConfig()
.setFilesFilter(changedFiles) // the files of the change
.setAnalysisDepth(1); // 0, the default, is an ordinary analysis
try (ProjectFiles base = new ProjectFiles(baseZip); // every file of the repository,
ProjectFiles head = new ProjectFiles(headZip)) { // not only the changed ones
StriffOperation op = new StriffOperation(base, head, config);
AnalysisScope scope = op.analysisScope(); // what was modelled and held back
}- Both revisions load the same files. Each revision is prepared, the files either revision references are joined, and both are compiled with that union, so a type referenced in only one revision is not shown as added or deleted.
- Boundary components. Components of the referenced files are modelled with
Component.isBoundary(), are drawn as gray context, and are serialised with"boundary": true. Their outgoing references past that level are not followed, so a missing edge from a boundary component says nothing. A boundary component becomes a key relations component only when the other end of the added or deleted relation is not a boundary component, and boundary components are the first removed when a diagram is overmaxComponentsPerDiagram. - Not-loaded relationships. A reference to a repository type that was not modelled has no
component to relate to, so it is not drawn. It is kept on
CodeDiff.notLoadedRelations()with the component it starts from, the kind of relation, and its origin:FOCUSwhen that component was analysed in full (for example, its target's file was held back by the budget),BOUNDARYwhen it is a boundary component's reference past the boundary. - Context files.
setExpandedFiles(...)names files to show as context, such as the files that use the changed code. In a one-level analysis they are modelled as boundary components, without their own references being followed. - Budgets, per language:
setLevelOneBudget(n)(default 1000) caps the referenced files andsetContextBudget(n)(default 200) the context files. Context files never displace referenced files: when the referenced files reach their budget, no context file is modelled. Each budget is exact. Over the context budget, the context files that name the filter's files most often are kept. - Focus extension. A
FocusExtendersees the first compile's base and head models once, and can name more files to analyse in full, for example files whose outgoing references a check depends on. Both revisions are extended and compiled again; the diff, relationships and diagram come from that final compile only.
config.setFocusExtender((baseModel, headModel) -> filesToCheckInFull(headModel));- Not for the incremental constructor, which has only one revision's files; it rejects a depth of 1.
- Incoming references are out of scope. Only outgoing references are followed. Every relationship a change adds or removes starts in a changed file, so it is found; an existing reference from an unchanged file into the changed code is seen only if that file is modelled (for example, as a context file).
Cleanup. Nothing a one-level analysis creates outlives the operation: its prepared analyses
are closed on success, failure and interruption, and any copy of the sources they write to disk
(TypeScript and Python resolve against files on disk; Java and C# do not) is deleted with them.
Close your ProjectFiles, and call ProjectFiles.deleteStaleTempDirs(Duration.ofHours(6)) at
startup to remove what a process killed without running its shutdown hooks left behind.
Start from an existing scheme and override only what you need:
DiagramColorScheme custom = DiagramColorSchemeOverride
.from(new LightDiagramColorScheme())
.setClassFontColor("#123456")
.setPackageFontName("Courier New");
StriffConfig config = new StriffConfig()
.setColorScheme(custom);You can also apply a display-only override:
DiagramDisplayOverride displayOverride = new DiagramDisplayOverride()
.setClassFontColor("#123456");
StriffConfig config = new StriffConfig()
.setDisplayOverride(displayOverride);Skip rendering diagrams but keep metadata (components, relations, change set):
StriffConfig config = new StriffConfig()
.setMetadataOnly(true);You can also cap rendering size; diagrams over the cap return metadata only:
StriffConfig config = new StriffConfig()
.setMaxComponentsPerDiagram(120);Striff supports extension points that can add components or decorate PlantUML output.
DiagramAugmenterruns during model construction and can add components or attach metadata to existing components (viaDiagramComponent.putAugmentation(...)).ClassDecoratorruns during PlantUML generation and injects PUML inside class blocks at specific insertion points.PackageDecoratorruns during PlantUML generation and injects PUML inside a specific package block. Use this for package-local notes or overlays that must stay scoped to one namespace.DiagramDecoratorruns during PlantUML generation and injects diagram-global PUML such as legends or skinparams.- Architecture details and examples:
architecture/adr-002-spi-extensions.md
Register implementations using Java ServiceLoader:
src/main/resources/META-INF/services/com.hadi.striff.spi.DiagramAugmenter
src/main/resources/META-INF/services/com.hadi.striff.spi.ClassDecorator
src/main/resources/META-INF/services/com.hadi.striff.spi.PackageDecorator
src/main/resources/META-INF/services/com.hadi.striff.spi.DiagramDecorator
Each file lists your implementation class names (one per line). Order is stable
using the order() method on each SPI.
If you have augmenters on the classpath, you can turn them off:
StriffConfig config = new StriffConfig()
.setEnableAugmenters(false);Every DiagramComponent is identified by uniqueName(), which is also the value of the
data-qualified-name attribute on the matching element of the rendered SVG. That is the
value to map a diagram element back to a component with; treat it as opaque.
A class is named by its package and its own name, com.example.MyClass. A synthetic
module — the container drawn for the module-level functions and fields of one source file,
as TypeScript and Python code commonly has — is named the same way:
<package path, dot separated>.module:<module name> src.orders.module:update
module:<module name> update.py in the root package
SyntheticModuleSupport.isSyntheticUniqueName(String) recognises both spellings.
Synthetic modules used to be keyed by the module's bare name alone. Two files of the same name in different directories therefore collapsed into one module, drawn in one package and holding both files' members:
| 4.3.x | 4.4.0 | |
|---|---|---|
src/orders/update.py |
src.orders.module:update — holding both files' members |
src.orders.module:update — holding its own |
src/billing/update.py |
(no component of its own) | src.billing.module:update |
What a consumer must do:
- The spelling of an id has not changed. A module that was the only one of its name keeps exactly the id it had, so stored ids for it stay valid.
- New ids appear wherever same-named files collapsed, and the surviving module's member list shrinks to its own file. Anything caching a module's members should be refreshed.
- Ids for collapsed modules were never stable: which package won depended on which file was encountered first, so the same codebase could produce a different id between runs. They are now deterministic.
- Do not parse the id to get a label.
name()is the module's own short name (update) and is serialized asname;packagecarries the package. Nothing needs to split the id.
5.0.0 removes StriffConfig.setResolveContextualComponents(boolean) and
StriffConfig.resolveContextualComponents(). That option looked up the source files of referenced
components by file name and parsed them. One-level analysis replaces it with Clarpse's own
resolution:
- Replace
.setResolveContextualComponents(true)with.setAnalysisDepth(1), and giveStriffOperationevery file of both revisions, not only the filtered ones. - Files you want shown as context go in
setExpandedFiles(...), as before. - With a depth of 1, read
CodeDiff.notLoadedRelations()alongsideextractedRels()if you need every reference of the analysed code: a reference to a repository type that was not modelled is in neitherinternalDependencies()norexternalDependencies()of its component, but innotLoadedDependencies().
Also in 5.0.0: rendering a diagram no longer removes relations to undrawn components from
CodeDiff.extractedRels(), so a diff rendered once still holds all of its relations.
- Library usage:
src/test/java/striff/test/model/StriffAPITest.java - More examples: See
src/test/java/for usage examples and regression tests.
- Build:
mvn clean package assembly:single - Run tests:
mvn test - See
src/test/java/for usage examples and regression tests. - See CONTRIBUTING.md for the full workflow.
striff-lib is released under the MIT License. You are free to use it in commercial and closed-source products.
Diagram rendering uses PlantUML, distributed under its MIT-licensed build. Images produced by running PlantUML are owned by the author of the corresponding source code, not by PlantUML.
Maintained by Hadi Technology, which also builds Striff, a hosted architecture-aware pull request reviewer built on top of this library.
