diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 07648c5824a9aa6c5d34534ca9a2186946474525..4526552a83f85c1affdbda45416e52eb8671913c 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -73,6 +73,8 @@ stages:
 #  - dast
   - staging
   - canary
+  - jar
+  - publish
   - production
   - incremental rollout 10%
   - incremental rollout 25%
@@ -127,17 +129,71 @@ trampoline-feedback:
     expire_in: 1 week
 
 checkstyle:
-  stage: test
+#  image: $CI_REGISTRY/$CI_PROJECT_PATH/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHA
   image: wukl/auta-build:0.1.0
+  stage: test
   script:
     - ./gradlew checkstyleMain checkstyleTest
 
 spotbugs:
-  stage: test
+#  image: $CI_REGISTRY/$CI_PROJECT_PATH/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHA
   image: wukl/auta-build:0.1.0
+  stage: test
   script:
     - ./gradlew :spotbugsMain :core:spotbugsMain :worker:spotbugsMain
 
+jar-core:
+#  image: $CI_REGISTRY/$CI_PROJECT_PATH/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHA
+  image: wukl/auta-build:0.1.0
+  stage: jar
+  script:
+    - ./gradlew bootJar
+  artifacts:
+    paths:
+      - core/build/libs/core-*.jar
+    expire_in: 1h
+
+jar-worker:
+#  image: $CI_REGISTRY/$CI_PROJECT_PATH/$CI_COMMIT_REF_NAME:$CI_COMMIT_SHA
+  image: wukl/auta-build:0.1.0
+  stage: jar
+  script:
+    - ./gradlew :worker:jar
+  artifacts:
+    paths:
+      - worker/build/libs/worker-*.jar
+    expire_in: 1h
+
+publish-core:
+  image: registry.gitlab.com/gitlab-org/cluster-integration/auto-build-image/master:stable
+  variables:
+    DOCKER_TLS_CERTDIR: ""
+  services:
+    - docker:19.03.5-dind
+  only:
+    - branches
+  stage: publish
+  dependencies:
+    - jar-core
+  script:
+    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+    - devops/build-slim-image core $CI_COMMIT_REF_NAME
+
+publish-worker:
+  image: registry.gitlab.com/gitlab-org/cluster-integration/auto-build-image/master:stable
+  variables:
+    DOCKER_TLS_CERTDIR: ""
+  services:
+    - docker:19.03.5-dind
+  only:
+    - branches
+  stage: publish
+  dependencies:
+    - jar-worker
+  script:
+    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
+    - devops/build-slim-image worker $CI_COMMIT_REF_NAME
+
 # Override DAST job to exclude master branch
 #dast:
 #  except:
diff --git a/devops/build-slim-image b/devops/build-slim-image
new file mode 100755
index 0000000000000000000000000000000000000000..3670308e02fa9f4844389702d5fa7f757fde87c6
--- /dev/null
+++ b/devops/build-slim-image
@@ -0,0 +1,44 @@
+#!/bin/bash
+set -e
+
+COMPONENT=$1
+TAG=$2
+
+if [ -z "$COMPONENT" ] || [ "$COMPONENT" = all ]
+then
+  ./$0 core $TAG
+  ./$0 worker $TAG
+  exit
+fi
+
+if [ -n "$TAG" ]
+then
+  TAG=-$TAG
+fi
+
+VERSION=$(grep -o "    version = '[^']*'" build.gradle | cut -d "'" -f 2 | tr -d '\n')
+
+/bin/echo -e "\033[33;1mReleasing AuTA $COMPONENT v$VERSION$TAG\033[0m"
+
+if [ $COMPONENT = core ]
+then
+  ARGS=
+else
+  ARGS='host=$COREADDRESS name=$WORKERNAME api-token=$APIKEY api-protocol=$APIPROTOCOL api-port=$APIPORT docker-api=$DOCKERAPI'
+fi
+
+docker build --no-cache --pull -t $CI_REGISTRY/$CI_PROJECT_PATH/$COMPONENT -f slim-$COMPONENT.Dockerfile . \
+    --build-arg VERSION=$VERSION --build-arg COMPONENT=$COMPONENT --build-arg ARGS="$ARGS"
+
+if [ "$TAG" = -local ]
+then
+  exit
+fi
+
+if [ "$TAG" = -latest ] || [ "$TAG" = -master ]
+then
+  docker push $CI_REGISTRY/$CI_PROJECT_PATH/$COMPONENT
+else
+  docker tag $CI_REGISTRY/$CI_PROJECT_PATH/$COMPONENT $CI_REGISTRY/$CI_PROJECT_PATH/$COMPONENT:$VERSION$TAG
+  docker push $CI_REGISTRY/$CI_PROJECT_PATH/$COMPONENT:$VERSION$TAG
+fi
diff --git a/src/main/java/nl/tudelft/ewi/auta/common/model/metric/MetricName.java b/src/main/java/nl/tudelft/ewi/auta/common/model/metric/MetricName.java
index 08df3757d2f7f9038de7a456303b3b6a90bbf248..1cfe53ddf9077c5aff8292157f3beed1d042f06d 100644
--- a/src/main/java/nl/tudelft/ewi/auta/common/model/metric/MetricName.java
+++ b/src/main/java/nl/tudelft/ewi/auta/common/model/metric/MetricName.java
@@ -393,7 +393,12 @@ public enum MetricName {
     /**
      * SpotBugs/FindBugs complaints.
      */
-    SPOTBUGS(Metric.class);
+    SPOTBUGS(Metric.class),
+
+    /**
+     * Call graph formatted as an SVG.
+     */
+    SVG_FLOW_GRAPH(StringMetric.class);
 
     /**
      * The type of the metric produced by instances with this name.
diff --git a/worker/src/main/antlr/nl/tudelft/ewi/auta/checker/grammar/AttAsm.g4 b/worker/src/main/antlr/nl/tudelft/ewi/auta/checker/grammar/AttAsm.g4
index 09c170843d154bb01242ca680781b9658a33e8c3..d4a96d72569bb4db7dc3f6e016206764fc5c5a07 100644
--- a/worker/src/main/antlr/nl/tudelft/ewi/auta/checker/grammar/AttAsm.g4
+++ b/worker/src/main/antlr/nl/tudelft/ewi/auta/checker/grammar/AttAsm.g4
@@ -151,6 +151,11 @@ LineComment
     -> channel(HIDDEN)
     ;
 
+LineCommentCStyle
+    : '//' ~'\n'*
+    -> channel(HIDDEN)
+    ;
+
 Whitespace
     : [ \t]+
     -> skip
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzer.java b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzer.java
index 0af74976aadefda83aeb49fe45f46e5402e24633..a2be70939835554c5601932f041c34609a7647a1 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzer.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzer.java
@@ -1,56 +1,37 @@
 package nl.tudelft.ewi.auta.checker.asm;
 
+import java.io.ByteArrayOutputStream;
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
-import java.util.function.Function;
 
+import net.sourceforge.plantuml.FileFormat;
+import net.sourceforge.plantuml.FileFormatOption;
+import net.sourceforge.plantuml.SourceStringReader;
 import nl.tudelft.ewi.auta.checker.EntityAnalyzer;
 import nl.tudelft.ewi.auta.common.model.entity.FileEntity;
 import nl.tudelft.ewi.auta.common.model.metric.IntegerMetric;
 import nl.tudelft.ewi.auta.common.model.metric.MetricName;
+import nl.tudelft.ewi.auta.common.model.metric.StringMetric;
 import nl.tudelft.ewi.auta.common.model.metric.StringSetMetric;
 import org.antlr.v4.runtime.CharStreams;
 import org.antlr.v4.runtime.CommonTokenStream;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 import nl.tudelft.ewi.auta.checker.grammar.AttAsmLexer;
 import nl.tudelft.ewi.auta.checker.grammar.AttAsmParser;
 import nl.tudelft.ewi.auta.common.model.metric.MetricCriteriaScript;
 
+import javax.annotation.Nullable;
+
 /**
  * An analyzer for AT&T-style x86_64 assembly.
  */
 public class AttAsmAnalyzer extends EntityAnalyzer {
-    private static final Logger logger = LoggerFactory.getLogger(AttAsmAnalyzer.class);
-
-    /**
-     * The set of functions used to extract metrics from an assembly info instance.
-     */
-    private static final Map<MetricName, Function<AttAsmReader.Info, Object>> METRIC_EXTRACTORS;
-
-    static {
-        METRIC_EXTRACTORS = new HashMap<>();
-
-        METRIC_EXTRACTORS.put(MetricName.COMMENTED_LINE_COUNT, info -> {
-            final var map = new HashMap<>();
-
-            map.put("comments", info.getCommentCount());
-            map.put("instructions", info.getInstructionCount());
-
-            return map;
-        });
-
-        METRIC_EXTRACTORS.put(
-                MetricName.ASSEMBLY_RECURSION_TARGET,
-                AttAsmReader.Info::getRecursionTargets
-        );
-    }
-
     /**
      * The assembly reader.
      */
@@ -89,6 +70,75 @@ public class AttAsmAnalyzer extends EntityAnalyzer {
                                            MetricName.ASSEMBLY_INSTRUCTION_COUNT));
         victim.addMetric(new StringSetMetric(info.getRecursionTargets(),
                                              MetricName.ASSEMBLY_RECURSION_TARGET));
+
+        try (var os = new ByteArrayOutputStream()) {
+            final var pumlReader = new SourceStringReader(this.formatAsPuml(info));
+            pumlReader.generateImage(os, new FileFormatOption(FileFormat.SVG));
+            victim.addMetric(new StringMetric(
+                    os.toString(StandardCharsets.UTF_8), MetricName.SVG_FLOW_GRAPH
+            ));
+        }
+    }
+
+    /**
+     * Formats the flow graph as PlantUML.
+     *
+     * @param info the file info
+     *
+     * @return the call graph as PlantUML
+     */
+    private String formatAsPuml(final AttAsmReader.Info info) {
+        final var builder = new StringBuilder("@startuml\n");
+
+        info.getBlocks().forEach(b -> {
+            final var name = b.getName();
+            final var sname = this.makePumlSafe(name);
+            builder.append("state \"").append(name).append("\" as ").append(sname)
+                    .append('\n');
+            b.getInstructions().forEach(i ->
+                    builder.append(sname).append(" : ").append(i).append('\n')
+            );
+        });
+
+        info.getBlocks().forEach(b -> {
+            final var sname = this.makePumlSafe(b.getName());
+            b.getCallTargets().forEach(t ->
+                    builder.append(sname).append(" --> ")
+                    .append(this.makePumlSafe(t)).append(" : call")
+                    .append('\n'));
+
+            @Nullable
+            final var jumpTargets = b.getJumpTargets();
+            if (jumpTargets == null || jumpTargets.isRet()) {
+                return;
+            }
+
+            if (jumpTargets.isConditional()) {
+                builder.append(sname).append(" --> ")
+                        .append(this.makePumlSafe(jumpTargets.getFalsyTarget())).append(" : false")
+                        .append('\n')
+                        .append(sname).append(" --> ")
+                        .append(this.makePumlSafe(jumpTargets.getTruthyTarget())).append(" : true")
+                        .append('\n');
+            } else {
+                builder.append(sname).append(" --> ")
+                        .append(this.makePumlSafe(jumpTargets.getTarget()))
+                        .append('\n');
+            }
+        });
+
+        return builder.append("@enduml\n").toString();
+    }
+
+    /**
+     * Translates a block label into one that is safe to use as a PlantUML label.
+     *
+     * @param name the unsafe label
+     *
+     * @return the safe label
+     */
+    private String makePumlSafe(final String name) {
+        return "S" + name.replaceAll("[.$\\-()?]", "_");
     }
 
     /**
@@ -108,7 +158,12 @@ public class AttAsmAnalyzer extends EntityAnalyzer {
      */
     @Override
     public Set<MetricName> getMetricNames() {
-        return METRIC_EXTRACTORS.keySet();
+        return Set.of(
+                MetricName.COMMENTED_LINE_COUNT,
+                MetricName.ASSEMBLY_INSTRUCTION_COUNT,
+                MetricName.ASSEMBLY_RECURSION_TARGET,
+                MetricName.SVG_FLOW_GRAPH
+        );
     }
 
     /**
@@ -185,6 +240,15 @@ public class AttAsmAnalyzer extends EntityAnalyzer {
             )
         ));
 
+        map.put(MetricName.SVG_FLOW_GRAPH, Collections.singletonList(
+            new MetricCriteriaScript("Emit as image",
+                  "(() => svg => {\n"
+                + "    // TODO: add support for script artifacts...\n"
+                + "    info(EDU_STAFF, svg);\n"
+                + "})()\n"
+            )
+        ));
+
         return map;
     }
 }
diff --git a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
index 65c7dbfbe25f48ca5265f0dee62f189846cddf22..e86ab4cf9675942437b8a661a6a94a807e5e8d44 100644
--- a/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
+++ b/worker/src/main/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReader.java
@@ -1,17 +1,24 @@
 package nl.tudelft.ewi.auta.checker.asm;
 
 import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.Random;
 import java.util.Set;
+import java.util.Stack;
 import java.util.regex.Pattern;
 
+import nl.tudelft.ewi.auta.common.annotation.Unmodifiable;
 import org.antlr.v4.runtime.CommonTokenStream;
+import org.antlr.v4.runtime.misc.Interval;
 import org.antlr.v4.runtime.tree.ParseTreeWalker;
 
 import nl.tudelft.ewi.auta.checker.grammar.AttAsmBaseListener;
 import nl.tudelft.ewi.auta.checker.grammar.AttAsmLexer;
 import nl.tudelft.ewi.auta.checker.grammar.AttAsmParser;
+import org.jetbrains.annotations.Contract;
 
 import javax.annotation.Nullable;
 
@@ -24,17 +31,22 @@ public class AttAsmReader {
     /**
      * The virtual call target for incomplete instructions.
      */
-    public static final String INVALID_CALL = "(invalid?)";
+    public static final String INVALID_JUMP = "(invalid?)";
 
     /**
      * The virtual call target for calls through registers.
      */
-    public static final String DYNAMIC_CALL = "(dynamic?)";
+    public static final String DYNAMIC_JUMP = "(dynamic?)";
+
+    /**
+     * The virtual jump target used by return instructions.
+     */
+    private static final JumpTargets RET_TARGET = new JumpTargets("#ret");
 
     /**
      * A regular expression matching an empty line comment.
      */
-    private static final String EMPTY_COMMENT_REGEX = "\\s*#\\s*$";
+    private static final String EMPTY_COMMENT_REGEX = "\\s*(#|//)\\s*$";
 
     /**
      * A pattern matching an empty line comment.
@@ -74,7 +86,35 @@ public class AttAsmReader {
     /**
      * Function call instructions.
      */
-    private static final Set<String> CALL_INSTRUCTIONS = Set.of("call");
+    private static final Set<String> CALL_INSTRUCTIONS = Collections.singleton("call");
+
+    /**
+     * Unconditional jump instructions.
+     */
+    private static final Set<String> JMP_INSTRUCTIONS = Collections.singleton("jmp");
+
+    /**
+     * Conditional jump instructions.
+     */
+    private static final Set<String> JCC_INSTRUCTIONS = Set.of(
+            "ja", "jae", "jb", "jbe", "jc", "jcxz", "jecxz", "jrcxz", "je", "jg", "jge",
+            "jl", "jle", "jna", "jnae", "jnb", "jnbe", "jnc", "jne", "jng", "jnge", "jnl",
+            "jnle", "jno", "jnp", "jns", "jnz", "jo", "jp", "jpe", "jpo", "js"
+    );
+
+    /**
+     * Function return instructions.
+     */
+    private static final Set<String> RET_INSTRUCTIONS = Collections.singleton("ret");
+
+    /**
+     * The random number generator to generate fairly non-colliding anonymous block identifiers.
+     */
+    private final Random random;
+
+    public AttAsmReader(final Random random) {
+        this.random = random;
+    }
 
     /**
      * Analyzes a file parsed by the AT&T assembly grammar.
@@ -97,6 +137,20 @@ public class AttAsmReader {
         return info;
     }
 
+    /**
+     * Creates an anonymous block unreachable instructions are placed in.
+     *
+     * Technically these instructions do not have to be unreachable due to the nature of assembly,
+     * but such trickery is asking for trouble.
+     *
+     * @return a new anonymous block
+     */
+    @Contract(" -> new")
+    private Block generateAnonymousBlock() {
+        // Generate multiples of 2 so that each block has no direct neighbors.
+        return new Block("", "", this.random.nextLong() * 2);
+    }
+
     /**
      * Walks the AST, recording useful information along the way.
      *
@@ -111,18 +165,52 @@ public class AttAsmReader {
             public void exitInstruction(final AttAsmParser.InstructionContext ctx) {
                 ++info.instructionCount;
 
-                if (CALL_INSTRUCTIONS.contains(ctx.Symbol().getText().toLowerCase())) {
-                    info.lastBlock().callTargets.add(AttAsmReader.this.getCallTarget(ctx, info));
+                final var lastBlock = info.lastBlock();
+                final var start = ctx.start;
+                final var end = ctx.stop;
+                final var cs = start.getTokenSource().getInputStream();
+                lastBlock.instructions.add(cs.getText(
+                        new Interval(start.getStartIndex(), end != null ? end.getStopIndex() : -1)
+                ));
+
+                final var instr = ctx.Symbol().getText().toLowerCase();
+                if (RET_INSTRUCTIONS.contains(instr)) {
+                    // If this is a return, end the current block and move to anonymous space
+                    lastBlock.jumpTargets = RET_TARGET;
+                    info.blocks.add(AttAsmReader.this.generateAnonymousBlock());
+                }
+
+                final var jumpTarget = AttAsmReader.this.getJumpTarget(ctx, info);
+                if (CALL_INSTRUCTIONS.contains(instr)) {
+                    // Function calls return to the current block (in theory)
+                    lastBlock.callTargets.add(jumpTarget);
+                } else if (JMP_INSTRUCTIONS.contains(instr)) {
+                    // Unconditional jumps end the current block
+                    lastBlock.jumpTargets = new JumpTargets(jumpTarget);
+                    info.blocks.add(AttAsmReader.this.generateAnonymousBlock());
+                } else if (JCC_INSTRUCTIONS.contains(instr)) {
+                    // Conditional jumps split the flow in two
+                    final var nextBlock  = info.lastBlock().advanceId();
+                    lastBlock.jumpTargets = new JumpTargets(nextBlock.getName(), jumpTarget);
+                    info.blocks.add(nextBlock);
                 }
             }
 
             @Override
             public void exitLabel(final AttAsmParser.LabelContext ctx) {
+                final var prevBlock = info.lastBlock();
+
                 if (ctx.getText().startsWith(".")) {
                     info.blocks.add(new Block(info.lastBlock().label, ctx.Symbol().getText()));
                 } else {
                     info.blocks.add(new Block(ctx.Symbol().getText(), null));
                 }
+
+                // If the previous block ended without jump instructions, simulate a jump to this
+                // block
+                if (prevBlock.jumpTargets == null) {
+                    prevBlock.jumpTargets = new JumpTargets(info.lastBlock().getName());
+                }
             }
         }, parser.file());
     }
@@ -132,17 +220,43 @@ public class AttAsmReader {
      *
      * This can not detect cross-file recursion.
      *
-     * @param info the info object to read class from and write conclusions to
+     * @param info the info object to read calls from and write conclusions to
      */
     private void detectRecursion(final Info info) {
-        final var visited = new HashSet<String>();
+        final var blocksByName = new HashMap<String, Block>();
+        info.getBlocks().forEach(block -> blocksByName.put(block.getName(), block));
+
+        for (final var iblock : info.getBlocks()) {
+            final var toVisit = new Stack<Block>();
+            final var visited = new HashSet<String>();
+            final var blocksCalled = new HashSet<String>();
+
+            toVisit.push(iblock);
+
+            while (!toVisit.empty()) {
+                System.out.println(toVisit);
 
-        for (final var block : info.blocks) {
-            visited.add(block.getName());
+                final var block = toVisit.pop();
+                if (visited.contains(block.getName())) {
+                    continue;
+                }
 
-            final var intersection = new HashSet<>(visited);
-            intersection.retainAll(block.callTargets);
-            info.recursionTargets.addAll(intersection);
+                visited.add(block.getName());
+                blocksCalled.addAll(block.callTargets);
+
+                if (block.jumpTargets != null) {
+                    if (block.jumpTargets.isConditional()) {
+                        toVisit.push(blocksByName.get(block.jumpTargets.getFalsyTarget()));
+                        toVisit.push(blocksByName.get(block.jumpTargets.getTruthyTarget()));
+                    } else if (!block.jumpTargets.isRet()) {
+                        toVisit.push(blocksByName.get(block.jumpTargets.getTarget()));
+                    }
+                }
+            }
+
+            if (blocksCalled.contains(iblock.getName())) {
+                info.recursionTargets.add(iblock.getName());
+            }
         }
     }
 
@@ -183,19 +297,19 @@ public class AttAsmReader {
     }
 
     /**
-     * Extracts the call target from an instruction.
+     * Extracts the call or jump target from an instruction.
      *
-     * May return special values {@link #INVALID_CALL} or {@link #DYNAMIC_CALL} if the call target
+     * May return special values {@link #INVALID_JUMP} or {@link #DYNAMIC_JUMP} if the target
      * is special.
      * *
-     * @param ctx the call instruction context
+     * @param ctx the call or jump instruction context
      * @param info the info block used to find which block a local label belongs to
      *
-     * @return the call target
+     * @return the target
      */
-    private String getCallTarget(final AttAsmParser.InstructionContext ctx, final Info info) {
+    private String getJumpTarget(final AttAsmParser.InstructionContext ctx, final Info info) {
         if (ctx.getChildCount() == 1) {
-            return INVALID_CALL;
+            return INVALID_JUMP;
         }
         final var operands = ctx.operands();
         final var first = operands.expression(0).getChild(0);
@@ -211,7 +325,7 @@ public class AttAsmReader {
             return name;
         }
 
-        return DYNAMIC_CALL;
+        return DYNAMIC_JUMP;
     }
 
     /**
@@ -274,12 +388,26 @@ public class AttAsmReader {
         public Set<String> getRecursionTargets() {
             return this.recursionTargets;
         }
+
+        /**
+         * Returns the logical blocks in the assembly file.
+         *
+         * @return the blocks
+         */
+        @Unmodifiable
+        public List<Block> getBlocks() {
+            return Collections.unmodifiableList(this.blocks);
+        }
     }
 
     /**
      * A logical block of assembly code.
+     *
+     * Blocks always have a label and always end with a jump or a return instructions. The label
+     * is derived from the label specified in the input, plus the number branches not taken
+     * within that block, called the block ID.
      */
-    private static final class Block {
+    public static final class Block {
         /**
          * The top-level label of the block.
          */
@@ -291,11 +419,36 @@ public class AttAsmReader {
         @Nullable
         private final String subLabel;
 
+        /**
+         * The full name of the block, generated on demand.
+         */
+        @Nullable
+        private transient String fullName;
+
+        /**
+         * The internal block branch identifier.
+         *
+         * This is a unique counter for each block and increases for every jump not taken within
+         * this block.
+         */
+        private final long branchId;
+
         /**
          * The set of blocks this block calls.
          */
         private final Set<String> callTargets = new HashSet<>();
 
+        /**
+         * The blocks this block jumps to at the end of its execution.
+         */
+        @Nullable
+        private JumpTargets jumpTargets;
+
+        /**
+         * The instructions in this block.
+         */
+        private final List<String> instructions = new ArrayList<>();
+
         /**
          * Creates a new block.
          *
@@ -303,24 +456,190 @@ public class AttAsmReader {
          * @param subLabel the sub-label
          */
         private Block(final String label, final @Nullable String subLabel) {
+            this(label, subLabel, 0);
+        }
+
+        /**
+         * Creates a new block.
+         *
+         * @param label the top-level label
+         * @param subLabel the sub-label
+         * @param branchId the jump-not-taken counter for the block with this name
+         */
+        private Block(final String label, final @Nullable String subLabel, final long branchId) {
             this.label = label;
             this.subLabel = subLabel;
+            this.branchId = branchId;
         }
 
         /**
          * Returns the logical name of the block.
          *
          * This is formed by taking the top-level label and appending a period and the sub-label,
-         * if it exists.
+         * if it exists. If this block is not the primary branch, a dollar sign and a branch
+         * identifier are appended.
          *
          * @return the name
          */
-        private String getName() {
+        @Contract(pure = true)
+        public String getName() {
+            if (this.fullName != null) {
+                return this.fullName;
+            }
+
             if (this.subLabel == null) {
-                return this.label;
+                if (this.branchId == 0) {
+                    return this.label;
+                }
+                return this.label + '$' + this.branchId;
+            }
+
+            if (this.branchId == 0) {
+                return this.label + '.' + this.subLabel;
             }
 
-            return this.label + '.' + this.subLabel;
+            this.fullName = this.label + '.' + this.subLabel + '$' + this.branchId;
+            return this.fullName;
+        }
+
+        /**
+         * Returns the names of the functions this block calls.
+         *
+         * @return the call targets
+         */
+        @Contract(pure = true)
+        @Unmodifiable
+        public Set<String> getCallTargets() {
+            return Collections.unmodifiableSet(this.callTargets);
+        }
+
+        /**
+         * Returns the instructions in this block.
+         *
+         * @return the instructions
+         */
+        @Contract(pure = true)
+        @Unmodifiable
+        public List<String> getInstructions() {
+            return Collections.unmodifiableList(this.instructions);
+        }
+
+        /**
+         * Returns the jump targets this block ends with.
+         *
+         * @return the jump targets
+         */
+        @Nullable
+        @Contract(pure = true)
+        public JumpTargets getJumpTargets() {
+            return this.jumpTargets;
+        }
+
+        /**
+         * Creates a block with this block's label and the next branch ID.
+         *
+         * @return the next block
+         */
+        @Contract(" -> new")
+        private Block advanceId() {
+            return new Block(this.label, this.subLabel, this.branchId + 1);
+        }
+    }
+
+    /**
+     * A set of jump targets for the end of a block.
+     *
+     * A special jump target for blocks returning from a call is {@link #RET_TARGET}. There is only
+     * one instance of this target.
+     */
+    public static final class JumpTargets {
+        /**
+         * The target the block jumps to if the condition is false.
+         */
+        private final String falsyTarget;
+
+        /**
+         * The target the block jumps to if the condition is true.
+         */
+        private final String truthyTarget;
+
+        /**
+         * Creates a new jump target for a conditional jump.
+         *
+         * @param falsyTarget the target the block jumps to if the condition is false
+         * @param truthyTarget the target the block jumps to if the condition is true
+         */
+        @Contract(pure = true)
+        private JumpTargets(final String falsyTarget, final String truthyTarget) {
+            this.falsyTarget = falsyTarget;
+            this.truthyTarget = truthyTarget;
+        }
+
+        /**
+         * Creates a new jump target for an unconditional jump.
+         *
+         * @param target the target the block jumps to
+         */
+        @Contract(pure = true)
+        private JumpTargets(final String target) {
+            this.falsyTarget = target;
+            this.truthyTarget = target;
+        }
+
+        /**
+         * Checks whether these targets are for a conditional jump.
+         *
+         * @return {@code true} if these targets are for a conditional jump, {@code false} if this
+         *         is a target for an unconditional jump or a return instruction
+         */
+        @Contract(pure = true)
+        public boolean isConditional() {
+            // noinspection StringEquality - identity is ensured by constructor
+            return this.falsyTarget != this.truthyTarget;
+        }
+
+        /**
+         * Checks whether this target is a return target.
+         *
+         * @return {@code true} if this target is a return target, {@code false} if this is a
+         *         target for an unconditional jump or if these targets are for a conditional jump
+         */
+        @Contract(pure = true)
+        public boolean isRet() {
+            return this == RET_TARGET;
+        }
+
+        /**
+         * Returns the target for an unconditional jump.
+         *
+         * @return the target
+         */
+        @Contract(pure = true)
+        public String getTarget() {
+            assert !this.isRet() && !this.isConditional() : "target is not unconditional";
+            return this.falsyTarget;
+        }
+
+        /**
+         * Returns the target for a true conditional jump.
+         *
+         * @return the target
+         */
+        @Contract(pure = true)
+        public String getTruthyTarget() {
+            assert this.isConditional() : "target is not conditional";
+            return this.truthyTarget;
+        }
+
+        /**
+         * Returns the target for a false conditional jump.
+         *
+         * @return the target
+         */
+        @Contract(pure = true)
+        public String getFalsyTarget() {
+            assert this.isConditional() : "target is not conditional";
+            return this.falsyTarget;
         }
     }
 }
diff --git a/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzerTest.java b/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzerTest.java
index 05d3692b6905f1c213cb1d22cccc6a05af2ebe5c..4c5f8058e2e6c16f92000d22e55f669a37703b2e 100644
--- a/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzerTest.java
+++ b/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmAnalyzerTest.java
@@ -14,6 +14,7 @@ import java.nio.charset.StandardCharsets;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.HashSet;
+import java.util.Random;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
@@ -24,7 +25,7 @@ public class AttAsmAnalyzerTest {
 
     @BeforeEach
     public void before() throws IOException {
-        this.analyzer = new AttAsmAnalyzer(new AttAsmReader());
+        this.analyzer = new AttAsmAnalyzer(new AttAsmReader(new Random(4)));
 
         this.temp = Files.createTempFile("test-", ".s");
     }
@@ -61,7 +62,7 @@ public class AttAsmAnalyzerTest {
         this.analyzer.analyze(entity, "option");
         assertThat(project.getChildren()).isNotEmpty();
         final var child = project.getChildren().iterator().next();
-        assertThat(child.getMetrics()).containsExactlyInAnyOrder(
+        assertThat(child.getMetrics()).contains(
                 new IntegerMetric(1, MetricName.COMMENTED_LINE_COUNT),
                 new IntegerMetric(1, MetricName.ASSEMBLY_INSTRUCTION_COUNT),
                 new StringSetMetric(new HashSet<>(), MetricName.ASSEMBLY_RECURSION_TARGET)
diff --git a/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReaderTest.java b/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReaderTest.java
index 122bdc60b380d654c738a5a81fb3caa0bcb00dd7..f4fbacb378dd135e451b8070ad2f32365719f552 100644
--- a/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReaderTest.java
+++ b/worker/src/test/java/nl/tudelft/ewi/auta/checker/asm/AttAsmReaderTest.java
@@ -6,15 +6,21 @@ import org.antlr.v4.runtime.CharStreams;
 import org.antlr.v4.runtime.CommonTokenStream;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Random;
 
 import static org.assertj.core.api.Assertions.assertThat;
 
 public class AttAsmReaderTest {
+    private static final Logger logger = LoggerFactory.getLogger(AttAsmReaderTest.class);
+
     private AttAsmReader reader;
 
     @BeforeEach
     public void before() {
-        this.reader = new AttAsmReader();
+        this.reader = new AttAsmReader(new Random(4));
     }
 
     @Test