Skip to content

Repository files navigation

striff-lib

Turn a code diff into an architectural diagram.

Maven Central Codacy Badge License: MIT maintained-by PRs Welcome

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.

sample_striff

Getting Started

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:single

Quickstart

Minimal 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
}

Configuration

Parsing and language support (Clarpse)

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.

Cancellation

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.

File filters

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.

One-level analysis

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 over maxComponentsPerDiagram.
  • 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: FOCUS when that component was analysed in full (for example, its target's file was held back by the budget), BOUNDARY when 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 and setContextBudget(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 FocusExtender sees 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.

Styling and color schemes

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);

Metadata-only output

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);

Augmentation and decorators (SPI)

Striff supports extension points that can add components or decorate PlantUML output.

  • DiagramAugmenter runs during model construction and can add components or attach metadata to existing components (via DiagramComponent.putAugmentation(...)).
  • ClassDecorator runs during PlantUML generation and injects PUML inside class blocks at specific insertion points.
  • PackageDecorator runs 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.
  • DiagramDecorator runs 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);

Component identity

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.

Breaking change in 4.4.0 for consumers that map components by unique name

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 as name; package carries the package. Nothing needs to split the id.

Migrating from 4.x

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 give StriffOperation every 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() alongside extractedRels() if you need every reference of the analysed code: a reference to a repository type that was not modelled is in neither internalDependencies() nor externalDependencies() of its component, but in notLoadedDependencies().

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.

Examples

  • Library usage: src/test/java/striff/test/model/StriffAPITest.java
  • More examples: See src/test/java/ for usage examples and regression tests.

Contributing

  • 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.

License

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.

About

A library for generating striff (structural diff) diagrams, Made For Code Reviews

Topics

Resources

Contributing

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages