Merge remote-tracking branch 'aosp/upstream-main'

* aosp/upstream-main: (86 commits)
  Handle unicode characters that require two UTF-16 code units
  Sort test inputs
  Update to setup-java v2, and use adopt builds
  Address compile errors that would appear when `ImmutableMap` is annotated for nullness in CL 382342656.
  Remove obsolete parent per https://github.com/sonatype/oss-parents
  Fix handling of repackaged transitive classes in jdeps
  Inline a single-use abstract test class
  Fix javadoc
  Inherit from the sonatype oss parent artifact
  Test invalid annotation element values are weeded out
  Satisfy the nullness checker.
  Use `assertThrows` for expected exception tests
  Don't require an argument for `--compress_jar`
  Satisfy the nullness checker
  Fix NPEs in options parsing
  Remove deprecated builders
  Rename `master` branch to `main`
  Never class-load `TurbineProcessingEnvironment` from the `-processor`
  Update Error Prone and maven versions
  Update turbine CI JDK versions
  ...

Bug: 193141629
Test: m checkbuild
Change-Id: If2e91cfa8c0b7d307acceb119b5ac4b195a4a237
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..87582f0
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,75 @@
+# Copyright 2020 Google Inc. All Rights Reserved.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: CI
+
+on:
+  push:
+    branches:
+    - main
+  pull_request:
+    branches:
+    - main
+
+jobs:
+  test:
+    name: "JDK ${{ matrix.java }} on ${{ matrix.os }}"
+    strategy:
+      fail-fast: false
+      matrix:
+        os: [ ubuntu-latest ]
+        java: [ 16, 11, 8 ]
+        experimental: [ false ]
+        include:
+          # Only test on macos and windows with a single recent JDK to avoid a
+          # combinatorial explosion of test configurations.
+          - os: macos-latest
+            java: 16
+            experimental: false
+          - os: windows-latest
+            java: 16
+            experimental: false
+          - os: ubuntu-latest
+            java: 17-ea
+            experimental: true
+    runs-on: ${{ matrix.os }}
+    continue-on-error: ${{ matrix.experimental }}
+    steps:
+      - name: Cancel previous
+        uses: styfle/cancel-workflow-action@0.8.0
+        with:
+          access_token: ${{ github.token }}
+      - name: 'Check out repository'
+        uses: actions/checkout@v2
+      - name: 'Cache local Maven repository'
+        uses: actions/cache@v2
+        with:
+          path: ~/.m2/repository
+          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+          restore-keys: |
+            ${{ runner.os }}-maven-
+      - name: 'Set up JDK ${{ matrix.java }}'
+        uses: actions/setup-java@v2
+        with:
+          java-version: ${{ matrix.java }}
+          distribution: 'adopt'
+      - name: 'Install'
+        shell: bash
+        run: mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
+      - name: 'Test'
+        shell: bash
+        run: mvn test -B
+      - name: 'Javadoc'
+        shell: bash
+        run: mvn javadoc:aggregate
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 8113f5f..0000000
--- a/.travis.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-language: java
-
-jdk:
-  - openjdk8
-  - openjdk9
-  - openjdk10
-  - openjdk11
-  - openjdk-ea
-
-matrix:
-  allow_failures:
-    - jdk: openjdk-ea
-
-# see https://github.com/travis-ci/travis-ci/issues/8408
-before_install:
-- unset _JAVA_OPTIONS
-
-# use travis-ci docker based infrastructure
-sudo: false
-
-cache:
-  directories:
-    - $HOME/.m2
-
-script:
-- mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
-- mvn test -B
diff --git a/appveyor.yml b/appveyor.yml
deleted file mode 100644
index 44ff736..0000000
--- a/appveyor.yml
+++ /dev/null
@@ -1,31 +0,0 @@
-os: Visual Studio 2015
-
-environment:
-  matrix:
-    - JAVA_HOME: C:\Program Files\Java\jdk9
-    - JAVA_HOME: C:\Program Files\Java\jdk10
-
-install:
-  - ps: |
-      Add-Type -AssemblyName System.IO.Compression.FileSystem
-      if (!(Test-Path -Path "C:\maven" )) {
-        (new-object System.Net.WebClient).DownloadFile(
-          'http://www.us.apache.org/dist/maven/maven-3/3.3.9/binaries/apache-maven-3.3.9-bin.zip',
-          'C:\maven-bin.zip'
-        )
-        [System.IO.Compression.ZipFile]::ExtractToDirectory("C:\maven-bin.zip", "C:\maven")
-      }
-  - cmd: SET PATH=C:\maven\apache-maven-3.3.9\bin;%JAVA_HOME%\bin;%PATH%
-  - cmd: SET MAVEN_OPTS=-XX:MaxPermSize=2g -Xmx4g
-  - cmd: SET JAVA_OPTS=-XX:MaxPermSize=2g -Xmx4g
-
-build_script:
-  - mvn install -DskipTests=true -Dmaven.javadoc.skip=true -B -V
-
-test_script:
-  - mvn test -B
-
-cache:
-  - C:\maven\
-  - C:\Users\appveyor\.m2
-
diff --git a/java/com/google/common/escape/SourceCodeEscapers.java b/java/com/google/common/escape/SourceCodeEscapers.java
index 4a1aa99..c0f9d6b 100644
--- a/java/com/google/common/escape/SourceCodeEscapers.java
+++ b/java/com/google/common/escape/SourceCodeEscapers.java
@@ -22,7 +22,7 @@
 /**
  * A factory for Escaper instances used to escape strings for safe use in Java.
  *
- * <p>This is a subset of source code escapers that are in the process of being open-sources as part
+ * <p>This is a subset of source code escapers that are in the process of being open-sourced as part
  * of guava, see: https://github.com/google/guava/issues/1620
  */
 // TODO(cushon): migrate to the guava version once it is open-sourced, and delete this
@@ -43,8 +43,8 @@
    * safely be included in either a Java character literal or string literal. This is the preferred
    * way to escape Java characters for use in String or character literals.
    *
-   * <p>See: <a href= "http://java.sun.com/docs/books/jls/third_edition/html/lexical.html#101089"
-   * >The Java Language Specification</a> for more details.
+   * <p>See: <a href="https://docs.oracle.com/javase/specs/jls/se14/html/jls-3.html#jls-3.10.6" >The
+   * Java Language Specification</a> for more details.
    */
   public static CharEscaper javaCharEscaper() {
     return JAVA_CHAR_ESCAPER;
@@ -66,7 +66,7 @@
   }
 
   // This escaper does not produce octal escape sequences. See:
-  // http://java.sun.com/docs/books/jls/third_edition/html/lexical.html#101089
+  // https://docs.oracle.com/javase/specs/jls/se14/html/jls-3.html#jls-3.10.6
   //  "Octal escapes are provided for compatibility with C, but can express
   //   only Unicode values \u0000 through \u00FF, so Unicode escapes are
   //   usually preferred."
diff --git a/java/com/google/turbine/binder/Binder.java b/java/com/google/turbine/binder/Binder.java
index 0e3f41f..6c828b3 100644
--- a/java/com/google/turbine/binder/Binder.java
+++ b/java/com/google/turbine/binder/Binder.java
@@ -54,6 +54,7 @@
 import com.google.turbine.binder.sym.FieldSymbol;
 import com.google.turbine.binder.sym.ModuleSymbol;
 import com.google.turbine.diag.SourceFile;
+import com.google.turbine.diag.TurbineDiagnostic;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.diag.TurbineError.ErrorKind;
 import com.google.turbine.diag.TurbineLog;
@@ -68,7 +69,7 @@
 import javax.annotation.processing.Processor;
 
 /** The entry point for analysis. */
-public class Binder {
+public final class Binder {
 
   /** Binds symbols and types to the given compilation units. */
   public static BindingResult bind(
@@ -87,19 +88,28 @@
       ClassPath bootclasspath,
       Optional<String> moduleVersion) {
     TurbineLog log = new TurbineLog();
-    BindingResult br =
-        bind(
-            log,
-            units,
-            /* generatedSources= */ ImmutableMap.of(),
-            /* generatedClasses= */ ImmutableMap.of(),
-            classpath,
-            bootclasspath,
-            moduleVersion);
-    if (!processorInfo.processors().isEmpty() && !units.isEmpty()) {
+    BindingResult br;
+    try {
       br =
-          Processing.process(
-              log, units, classpath, processorInfo, bootclasspath, br, moduleVersion);
+          bind(
+              log,
+              units,
+              /* generatedSources= */ ImmutableMap.of(),
+              /* generatedClasses= */ ImmutableMap.of(),
+              classpath,
+              bootclasspath,
+              moduleVersion);
+      if (!processorInfo.processors().isEmpty() && !units.isEmpty()) {
+        br =
+            Processing.process(
+                log, units, classpath, processorInfo, bootclasspath, br, moduleVersion);
+      }
+    } catch (TurbineError turbineError) {
+      throw new TurbineError(
+          ImmutableList.<TurbineDiagnostic>builder()
+              .addAll(log.diagnostics())
+              .addAll(turbineError.diagnostics())
+              .build());
     }
     log.maybeThrow();
     return br;
@@ -540,4 +550,6 @@
           units, modules, classPathEnv, tli, generatedSources, generatedClasses, statistics);
     }
   }
+
+  private Binder() {}
 }
diff --git a/java/com/google/turbine/binder/CanonicalTypeBinder.java b/java/com/google/turbine/binder/CanonicalTypeBinder.java
index a2f045a..ae82b4f 100644
--- a/java/com/google/turbine/binder/CanonicalTypeBinder.java
+++ b/java/com/google/turbine/binder/CanonicalTypeBinder.java
@@ -38,19 +38,15 @@
 /**
  * Canonicalizes all qualified types in a {@link SourceTypeBoundClass} using {@link Canonicalize}.
  */
-public class CanonicalTypeBinder {
+public final class CanonicalTypeBinder {
 
   static SourceTypeBoundClass bind(
       ClassSymbol sym, SourceTypeBoundClass base, Env<ClassSymbol, TypeBoundClass> env) {
-    ClassTy superClassType = null;
-    if (base.superClassType() != null && base.superClassType().tyKind() == TyKind.CLASS_TY) {
+    Type superClassType = base.superClassType();
+    if (superClassType != null && superClassType.tyKind() == TyKind.CLASS_TY) {
       superClassType =
           Canonicalize.canonicalizeClassTy(
-              base.source(),
-              base.decl().position(),
-              env,
-              base.owner(),
-              (ClassTy) base.superClassType());
+              base.source(), base.decl().position(), env, base.owner(), (ClassTy) superClassType);
     }
     ImmutableList.Builder<Type> interfaceTypes = ImmutableList.builder();
     for (Type i : base.interfaceTypes()) {
@@ -133,9 +129,7 @@
               base.defaultValue(),
               base.decl(),
               base.annotations(),
-              base.receiver() != null
-                  ? param(source, base.decl().position(), env, sym, base.receiver())
-                  : null));
+              base.receiver() != null ? param(source, pos, env, sym, base.receiver()) : null));
     }
     return result.build();
   }
@@ -181,4 +175,6 @@
     }
     return result.build();
   }
+
+  private CanonicalTypeBinder() {}
 }
diff --git a/java/com/google/turbine/binder/ClassPathBinder.java b/java/com/google/turbine/binder/ClassPathBinder.java
index 8aead80..1825c23 100644
--- a/java/com/google/turbine/binder/ClassPathBinder.java
+++ b/java/com/google/turbine/binder/ClassPathBinder.java
@@ -38,7 +38,7 @@
 import java.util.Map;
 
 /** Sets up an environment for symbols on the classpath. */
-public class ClassPathBinder {
+public final class ClassPathBinder {
 
   /**
    * The prefix for repackaged transitive dependencies; see {@link
@@ -148,4 +148,6 @@
           }
         });
   }
+
+  private ClassPathBinder() {}
 }
diff --git a/java/com/google/turbine/binder/CompUnitPreprocessor.java b/java/com/google/turbine/binder/CompUnitPreprocessor.java
index ed70e88..9e9a0bb 100644
--- a/java/com/google/turbine/binder/CompUnitPreprocessor.java
+++ b/java/com/google/turbine/binder/CompUnitPreprocessor.java
@@ -45,7 +45,7 @@
  * Processes compilation units before binding, creating symbols for type declarations and desugaring
  * access modifiers.
  */
-public class CompUnitPreprocessor {
+public final class CompUnitPreprocessor {
 
   /** A pre-processed compilation unit. */
   public static class PreprocessedCompUnit {
@@ -222,4 +222,6 @@
         TurbineTyKind.INTERFACE,
         /* javadoc= */ null);
   }
+
+  private CompUnitPreprocessor() {}
 }
diff --git a/java/com/google/turbine/binder/ConstBinder.java b/java/com/google/turbine/binder/ConstBinder.java
index 3a41e94..8511183 100644
--- a/java/com/google/turbine/binder/ConstBinder.java
+++ b/java/com/google/turbine/binder/ConstBinder.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.binder;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
@@ -196,6 +198,9 @@
 
   private static RetentionPolicy bindRetention(AnnoInfo annotation) {
     Const value = annotation.values().get("value");
+    if (value == null) {
+      return null;
+    }
     if (value.kind() != Kind.ENUM_CONSTANT) {
       return null;
     }
@@ -208,7 +213,8 @@
 
   private static ImmutableSet<TurbineElementType> bindTarget(AnnoInfo annotation) {
     ImmutableSet.Builder<TurbineElementType> result = ImmutableSet.builder();
-    Const val = annotation.values().get("value");
+    // requireNonNull is safe because java.lang.annotation.Target declares `value`.
+    Const val = requireNonNull(annotation.values().get("value"));
     switch (val.kind()) {
       case ARRAY:
         for (Const element : ((ArrayInitValue) val).elements()) {
@@ -227,7 +233,8 @@
   }
 
   private static ClassSymbol bindRepeatable(AnnoInfo annotation) {
-    Const value = annotation.values().get("value");
+    // requireNonNull is safe because java.lang.annotation.Repeatable declares `value`.
+    Const value = requireNonNull(annotation.values().get("value"));
     if (value.kind() != Kind.CLASS_LITERAL) {
       return null;
     }
@@ -268,11 +275,12 @@
     if ((base.access() & TurbineFlag.ACC_FINAL) == 0) {
       return null;
     }
-    switch (base.type().tyKind()) {
+    Type type = base.type();
+    switch (type.tyKind()) {
       case PRIM_TY:
         break;
       case CLASS_TY:
-        if (((Type.ClassTy) base.type()).sym().equals(ClassSymbol.STRING)) {
+        if (((Type.ClassTy) type).sym().equals(ClassSymbol.STRING)) {
           break;
         }
         // falls through
@@ -280,8 +288,11 @@
         return null;
     }
     Value value = constantEnv.get(base.sym());
-    if (value != null) {
-      value = (Value) ConstEvaluator.cast(base.type(), value);
+    if (value == null) {
+      return null;
+    }
+    if (type.tyKind().equals(TyKind.PRIM_TY)) {
+      value = ConstEvaluator.coerce(value, ((Type.PrimTy) type).primkind());
     }
     return value;
   }
diff --git a/java/com/google/turbine/binder/ConstEvaluator.java b/java/com/google/turbine/binder/ConstEvaluator.java
index 9d5f042..bef98a7 100644
--- a/java/com/google/turbine/binder/ConstEvaluator.java
+++ b/java/com/google/turbine/binder/ConstEvaluator.java
@@ -47,6 +47,7 @@
 import com.google.turbine.model.Const.Value;
 import com.google.turbine.model.TurbineConstantTypeKind;
 import com.google.turbine.model.TurbineFlag;
+import com.google.turbine.model.TurbineTyKind;
 import com.google.turbine.tree.Tree;
 import com.google.turbine.tree.Tree.ArrayInit;
 import com.google.turbine.tree.Tree.Binary;
@@ -126,22 +127,13 @@
           }
           switch (a.constantTypeKind()) {
             case CHAR:
-              return new Const.CharValue(((com.google.turbine.model.Const.CharValue) a).value());
             case INT:
-              return new Const.IntValue(((com.google.turbine.model.Const.IntValue) a).value());
             case LONG:
-              return new Const.LongValue(((com.google.turbine.model.Const.LongValue) a).value());
             case FLOAT:
-              return new Const.FloatValue(((com.google.turbine.model.Const.FloatValue) a).value());
             case DOUBLE:
-              return new Const.DoubleValue(
-                  ((com.google.turbine.model.Const.DoubleValue) a).value());
             case BOOLEAN:
-              return new Const.BooleanValue(
-                  ((com.google.turbine.model.Const.BooleanValue) a).value());
             case STRING:
-              return new Const.StringValue(
-                  ((com.google.turbine.model.Const.StringValue) a).value());
+              return a;
             case SHORT:
             case BYTE:
             case NULL:
@@ -318,20 +310,24 @@
   }
 
   /** Casts the value to the given type. */
-  static Const cast(Type ty, Const value) {
+  private Const cast(int position, Type ty, Const value) {
     checkNotNull(value);
     switch (ty.tyKind()) {
       case CLASS_TY:
       case TY_VAR:
         return value;
       case PRIM_TY:
+        if (!value.kind().equals(Const.Kind.PRIMITIVE)) {
+          throw error(position, ErrorKind.EXPRESSION_ERROR);
+        }
         return coerce((Const.Value) value, ((Type.PrimTy) ty).primkind());
       default:
         throw new AssertionError(ty.tyKind());
     }
   }
 
-  private static Const.Value coerce(Const.Value value, TurbineConstantTypeKind kind) {
+  /** Casts the constant value to the given type. */
+  static Const.Value coerce(Const.Value value, TurbineConstantTypeKind kind) {
     switch (kind) {
       case BOOLEAN:
         return value.asBoolean();
@@ -925,12 +921,16 @@
     if (info.sym() == null) {
       return info;
     }
-
-    Map<String, Type> template = new LinkedHashMap<>();
     TypeBoundClass annoClass = env.get(info.sym());
+    if (annoClass.kind() != TurbineTyKind.ANNOTATION) {
+      // we've already reported an error for non-annotation symbols used as annotations,
+      // skip error handling for annotation arguments
+      return info;
+    }
+    Map<String, MethodInfo> template = new LinkedHashMap<>();
     if (annoClass != null) {
       for (MethodInfo method : annoClass.methods()) {
-        template.put(method.name(), method.returnType());
+        template.put(method.name(), method);
       }
     }
 
@@ -947,20 +947,28 @@
         key = "value";
         expr = arg;
       }
-      Type ty = template.get(key);
-      if (ty == null) {
-        throw error(
+      MethodInfo methodInfo = template.remove(key);
+      if (methodInfo == null) {
+        log.error(
             arg.position(),
             ErrorKind.CANNOT_RESOLVE,
             String.format("element %s() in %s", key, info.sym()));
+        continue;
       }
-      Const value = evalAnnotationValue(expr, ty);
+      Const value = evalAnnotationValue(expr, methodInfo.returnType());
       if (value == null) {
-        throw error(expr.position(), ErrorKind.EXPRESSION_ERROR);
+        log.error(expr.position(), ErrorKind.EXPRESSION_ERROR);
+        continue;
       }
       Const existing = values.put(key, value);
       if (existing != null) {
-        throw error(arg.position(), ErrorKind.INVALID_ANNOTATION_ARGUMENT);
+        log.error(arg.position(), ErrorKind.INVALID_ANNOTATION_ARGUMENT);
+        continue;
+      }
+    }
+    for (MethodInfo methodInfo : template.values()) {
+      if (!methodInfo.hasDefaultValue()) {
+        log.error(info.tree().position(), ErrorKind.MISSING_ANNOTATION_ARGUMENT, methodInfo.name());
       }
     }
     return info.withValues(ImmutableMap.copyOf(values));
@@ -969,8 +977,9 @@
   private TurbineAnnotationValue evalAnno(Tree.Anno t) {
     LookupResult result = scope.lookup(new LookupKey(t.name()));
     if (result == null) {
-      throw error(
+      log.error(
           t.name().get(0).position(), ErrorKind.CANNOT_RESOLVE, Joiner.on(".").join(t.name()));
+      return null;
     }
     ClassSymbol sym = (ClassSymbol) result.sym();
     for (Ident name : result.remaining()) {
@@ -982,6 +991,9 @@
     if (sym == null) {
       return null;
     }
+    if (env.get(sym).kind() != TurbineTyKind.ANNOTATION) {
+      log.error(t.position(), ErrorKind.NOT_AN_ANNOTATION, sym);
+    }
     AnnoInfo annoInfo = evaluateAnnotation(new AnnoInfo(source, sym, t, ImmutableMap.of()));
     return new TurbineAnnotationValue(annoInfo);
   }
@@ -1004,7 +1016,8 @@
     }
     Const value = eval(tree);
     if (value == null) {
-      throw error(tree.position(), ErrorKind.EXPRESSION_ERROR);
+      log.error(tree.position(), ErrorKind.EXPRESSION_ERROR);
+      return null;
     }
     switch (ty.tyKind()) {
       case PRIM_TY:
@@ -1024,7 +1037,7 @@
                   : ImmutableList.of(value);
           ImmutableList.Builder<Const> coerced = ImmutableList.builder();
           for (Const element : elements) {
-            coerced.add(cast(elementType, element));
+            coerced.add(cast(tree.position(), elementType, element));
           }
           return new Const.ArrayInitValue(coerced.build());
         }
@@ -1043,7 +1056,7 @@
       if (value == null || value.kind() != Const.Kind.PRIMITIVE) {
         return null;
       }
-      return (Const.Value) cast(type, value);
+      return (Const.Value) cast(expression.position(), type, value);
     } catch (TurbineError error) {
       for (TurbineDiagnostic diagnostic : error.diagnostics()) {
         switch (diagnostic.kind()) {
diff --git a/java/com/google/turbine/binder/CtSymClassBinder.java b/java/com/google/turbine/binder/CtSymClassBinder.java
index a6f1b3d..1d7ece7 100644
--- a/java/com/google/turbine/binder/CtSymClassBinder.java
+++ b/java/com/google/turbine/binder/CtSymClassBinder.java
@@ -16,11 +16,15 @@
 
 package com.google.turbine.binder;
 
+import static com.google.common.base.Ascii.toUpperCase;
 import static com.google.common.base.StandardSystemProperty.JAVA_HOME;
+import static java.util.Objects.requireNonNull;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.primitives.Ints;
 import com.google.turbine.binder.bound.ModuleInfo;
 import com.google.turbine.binder.bytecode.BytecodeBinder;
 import com.google.turbine.binder.bytecode.BytecodeBoundClass;
@@ -32,6 +36,7 @@
 import com.google.turbine.binder.sym.ModuleSymbol;
 import com.google.turbine.zip.Zip;
 import java.io.IOException;
+import java.lang.reflect.Method;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
@@ -40,12 +45,13 @@
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** Constructs a platform {@link ClassPath} from the current JDK's ct.sym file. */
-public class CtSymClassBinder {
+public final class CtSymClassBinder {
 
   @Nullable
   public static ClassPath bind(String version) throws IOException {
-    Path javaHome = Paths.get(JAVA_HOME.value());
-    Path ctSym = javaHome.resolve("lib/ct.sym");
+    String javaHome = JAVA_HOME.value();
+    requireNonNull(javaHome, "attempted to use --release, but JAVA_HOME is not set");
+    Path ctSym = Paths.get(javaHome).resolve("lib/ct.sym");
     if (!Files.exists(ctSym)) {
       throw new IllegalStateException("lib/ct.sym does not exist in " + javaHome);
     }
@@ -59,7 +65,9 @@
           }
         };
     // ct.sym contains directories whose names are the concatentation of a list of target versions
-    // (e.g. 789) and which contain interface class files with a .sig extension.
+    // formatted as a single character 0-9 or A-Z (e.g. 789A) and which contain interface class
+    // files with a .sig extension.
+    String releaseString = formatReleaseVersion(version);
     for (Zip.Entry ze : new Zip.ZipIterable(ctSym)) {
       String name = ze.name();
       if (!name.endsWith(".sig")) {
@@ -70,10 +78,13 @@
         continue;
       }
       // check if the directory matches the desired release
-      // TODO(cushon): what happens when version numbers contain more than one digit?
-      if (!ze.name().substring(0, idx).contains(version)) {
+      if (!ze.name().substring(0, idx).contains(releaseString)) {
         continue;
       }
+      if (isAtLeastJDK12()) {
+        // JDK >= 12 includes the module name as a prefix
+        idx = name.indexOf('/', idx + 1);
+      }
       if (name.substring(name.lastIndexOf('/') + 1).equals("module-info.sig")) {
         ModuleInfo moduleInfo = BytecodeBinder.bindModuleInfo(name, toByteArrayOrDie(ze));
         modules.put(new ModuleSymbol(moduleInfo.name()), moduleInfo);
@@ -122,4 +133,28 @@
           }
         });
   }
+
+  @VisibleForTesting
+  static String formatReleaseVersion(String version) {
+    Integer n = Ints.tryParse(version);
+    if (n == null || n <= 4 || n >= 36) {
+      throw new IllegalArgumentException("invalid release version: " + version);
+    }
+    return toUpperCase(Integer.toString(n, 36));
+  }
+
+  private static boolean isAtLeastJDK12() {
+    int major;
+    try {
+      Method versionMethod = Runtime.class.getMethod("version");
+      Object version = versionMethod.invoke(null);
+      major = (int) version.getClass().getMethod("major").invoke(version);
+    } catch (ReflectiveOperationException e) {
+      // `Runtime.version()` was added in JDK 9
+      return false;
+    }
+    return major >= 12;
+  }
+
+  private CtSymClassBinder() {}
 }
diff --git a/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java b/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java
index 7e3fbda..c5de8c1 100644
--- a/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java
+++ b/java/com/google/turbine/binder/DisambiguateTypeAnnotations.java
@@ -65,7 +65,7 @@
  * constant binding is done, read the {@code @Target} meta-annotation for each ambiguous annotation,
  * and move it to the appropriate location.
  */
-public class DisambiguateTypeAnnotations {
+public final class DisambiguateTypeAnnotations {
   public static SourceTypeBoundClass bind(
       SourceTypeBoundClass base, Env<ClassSymbol, TypeBoundClass> env) {
     return new SourceTypeBoundClass(
@@ -317,4 +317,6 @@
     }
     return false;
   }
+
+  private DisambiguateTypeAnnotations() {}
 }
diff --git a/java/com/google/turbine/binder/FileManagerClassBinder.java b/java/com/google/turbine/binder/FileManagerClassBinder.java
new file mode 100644
index 0000000..42a8162
--- /dev/null
+++ b/java/com/google/turbine/binder/FileManagerClassBinder.java
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2020 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import com.google.common.base.Joiner;
+import com.google.common.base.Supplier;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.io.ByteStreams;
+import com.google.turbine.binder.bound.ModuleInfo;
+import com.google.turbine.binder.bytecode.BytecodeBoundClass;
+import com.google.turbine.binder.env.Env;
+import com.google.turbine.binder.env.SimpleEnv;
+import com.google.turbine.binder.lookup.LookupKey;
+import com.google.turbine.binder.lookup.LookupResult;
+import com.google.turbine.binder.lookup.PackageScope;
+import com.google.turbine.binder.lookup.Scope;
+import com.google.turbine.binder.lookup.TopLevelIndex;
+import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.binder.sym.ModuleSymbol;
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Map;
+import javax.tools.FileObject;
+import javax.tools.JavaFileObject;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import org.checkerframework.checker.nullness.qual.Nullable;
+
+/**
+ * Binds a {@link StandardJavaFileManager} to an {@link ClassPath}. This can be used to share a
+ * filemanager (and associated IO costs) between turbine and javac when running both in the same
+ * process.
+ */
+public final class FileManagerClassBinder {
+
+  public static ClassPath adapt(StandardJavaFileManager fileManager, StandardLocation location) {
+    PackageLookup packageLookup = new PackageLookup(fileManager, location);
+    Env<ClassSymbol, BytecodeBoundClass> env =
+        new Env<ClassSymbol, BytecodeBoundClass>() {
+          @Override
+          public BytecodeBoundClass get(ClassSymbol sym) {
+            return packageLookup.getPackage(this, sym.packageName()).get(sym);
+          }
+        };
+    SimpleEnv<ModuleSymbol, ModuleInfo> moduleEnv = new SimpleEnv<>(ImmutableMap.of());
+    TopLevelIndex tli = new FileManagerTopLevelIndex(env, packageLookup);
+    return new ClassPath() {
+      @Override
+      public Env<ClassSymbol, BytecodeBoundClass> env() {
+        return env;
+      }
+
+      @Override
+      public Env<ModuleSymbol, ModuleInfo> moduleEnv() {
+        return moduleEnv;
+      }
+
+      @Override
+      public TopLevelIndex index() {
+        return tli;
+      }
+
+      @Override
+      public Supplier<byte[]> resource(String path) {
+        return packageLookup.resource(path);
+      }
+    };
+  }
+
+  private static class PackageLookup {
+
+    private final Map<String, Map<ClassSymbol, BytecodeBoundClass>> packages = new HashMap<>();
+    private final StandardJavaFileManager fileManager;
+    private final StandardLocation location;
+
+    private PackageLookup(StandardJavaFileManager fileManager, StandardLocation location) {
+      this.fileManager = fileManager;
+      this.location = location;
+    }
+
+    private ImmutableMap<ClassSymbol, BytecodeBoundClass> listPackage(
+        Env<ClassSymbol, BytecodeBoundClass> env, String packageName) throws IOException {
+      Map<ClassSymbol, BytecodeBoundClass> result = new HashMap<>();
+      for (JavaFileObject jfo :
+          fileManager.list(
+              location,
+              packageName.replace('/', '.'),
+              EnumSet.of(JavaFileObject.Kind.CLASS),
+              false)) {
+        String binaryName = fileManager.inferBinaryName(location, jfo);
+        ClassSymbol sym = new ClassSymbol(binaryName.replace('.', '/'));
+        result.putIfAbsent(
+            sym,
+            new BytecodeBoundClass(
+                sym,
+                new Supplier<byte[]>() {
+                  @Override
+                  public byte[] get() {
+                    try {
+                      return ByteStreams.toByteArray(jfo.openInputStream());
+                    } catch (IOException e) {
+                      throw new UncheckedIOException(e);
+                    }
+                  }
+                },
+                env,
+                /* jarFile= */ null));
+      }
+      return ImmutableMap.copyOf(result);
+    }
+
+    private Map<ClassSymbol, BytecodeBoundClass> getPackage(
+        Env<ClassSymbol, BytecodeBoundClass> env, String key) {
+      return packages.computeIfAbsent(
+          key,
+          k -> {
+            try {
+              return listPackage(env, key);
+            } catch (IOException e) {
+              throw new UncheckedIOException(e);
+            }
+          });
+    }
+
+    public Supplier<byte[]> resource(String resource) {
+      String dir;
+      String name;
+      int idx = resource.lastIndexOf('/');
+      if (idx != -1) {
+        dir = resource.substring(0, idx + 1);
+        name = resource.substring(idx + 1, resource.length());
+      } else {
+        dir = "";
+        name = resource;
+      }
+      FileObject fileObject;
+      try {
+        fileObject = fileManager.getFileForInput(location, dir, name);
+      } catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+      if (fileObject == null) {
+        return null;
+      }
+      return new Supplier<byte[]>() {
+        @Override
+        public byte[] get() {
+          try {
+            return ByteStreams.toByteArray(fileObject.openInputStream());
+          } catch (IOException e) {
+            throw new UncheckedIOException(e);
+          }
+        }
+      };
+    }
+  }
+
+  private static class FileManagerTopLevelIndex implements TopLevelIndex {
+    private final Env<ClassSymbol, BytecodeBoundClass> env;
+    private final PackageLookup packageLookup;
+
+    public FileManagerTopLevelIndex(
+        Env<ClassSymbol, BytecodeBoundClass> env, PackageLookup packageLookup) {
+      this.env = env;
+      this.packageLookup = packageLookup;
+    }
+
+    @Override
+    public Scope scope() {
+      return new Scope() {
+        @Override
+        public @Nullable LookupResult lookup(LookupKey lookupKey) {
+          for (int i = lookupKey.simpleNames().size(); i > 0; i--) {
+            String p = Joiner.on('/').join(lookupKey.simpleNames().subList(0, i));
+            ClassSymbol sym = new ClassSymbol(p);
+            BytecodeBoundClass r = env.get(sym);
+            if (r != null) {
+              return new LookupResult(
+                  sym,
+                  new LookupKey(
+                      lookupKey.simpleNames().subList(i - 1, lookupKey.simpleNames().size())));
+            }
+          }
+          return null;
+        }
+      };
+    }
+
+    @Override
+    public PackageScope lookupPackage(Iterable<String> names) {
+      String packageName = Joiner.on('/').join(names);
+      Map<ClassSymbol, BytecodeBoundClass> pkg = packageLookup.getPackage(env, packageName);
+      if (pkg.isEmpty()) {
+        return null;
+      }
+      return new PackageScope() {
+        @Override
+        public Iterable<ClassSymbol> classes() {
+          return pkg.keySet();
+        }
+
+        @Override
+        public @Nullable LookupResult lookup(LookupKey lookupKey) {
+          String className = lookupKey.first().value();
+          if (!packageName.isEmpty()) {
+            className = packageName + "/" + className;
+          }
+          ClassSymbol sym = new ClassSymbol(className);
+          if (!pkg.containsKey(sym)) {
+            return null;
+          }
+          return new LookupResult(sym, lookupKey);
+        }
+      };
+    }
+  }
+
+  private FileManagerClassBinder() {}
+}
diff --git a/java/com/google/turbine/binder/ModuleBinder.java b/java/com/google/turbine/binder/ModuleBinder.java
index 748ff39..04ce81d 100644
--- a/java/com/google/turbine/binder/ModuleBinder.java
+++ b/java/com/google/turbine/binder/ModuleBinder.java
@@ -216,11 +216,12 @@
     }
     ClassSymbol sym = (ClassSymbol) result.sym();
     for (Tree.Ident name : result.remaining()) {
-      sym = Resolve.resolve(env, /* origin= */ null, sym, name);
-      if (sym == null) {
+      ClassSymbol next = Resolve.resolve(env, /* origin= */ null, sym, name);
+      if (next == null) {
         throw error(
             ErrorKind.SYMBOL_NOT_FOUND, pos, new ClassSymbol(sym.binaryName() + '$' + name));
       }
+      sym = next;
     }
     return sym;
   }
diff --git a/java/com/google/turbine/binder/Processing.java b/java/com/google/turbine/binder/Processing.java
index ecdf195..16407aa 100644
--- a/java/com/google/turbine/binder/Processing.java
+++ b/java/com/google/turbine/binder/Processing.java
@@ -16,7 +16,10 @@
 
 package com.google.turbine.binder;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.auto.value.AutoValue;
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Function;
 import com.google.common.base.Joiner;
 import com.google.common.base.Stopwatch;
@@ -27,6 +30,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.ImmutableSetMultimap;
 import com.google.common.collect.Sets;
+import com.google.common.primitives.Ints;
 import com.google.turbine.binder.Binder.BindingResult;
 import com.google.turbine.binder.Binder.Statistics;
 import com.google.turbine.binder.bound.SourceTypeBoundClass;
@@ -56,7 +60,6 @@
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Collection;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.Iterator;
 import java.util.LinkedHashMap;
@@ -76,6 +79,7 @@
 /** Top level annotation processing logic, see also {@link Binder}. */
 public class Processing {
 
+  @Nullable
   static BindingResult process(
       TurbineLog log,
       final ImmutableList<CompUnit> initialSources,
@@ -131,19 +135,13 @@
       try (Timers.Timer unused = timers.start(processor)) {
         processor.init(processingEnv);
       } catch (Throwable t) {
-        reportProcessorCrash(log, processor, t);
+        logProcessorCrash(log, processor, t);
+        return null;
       }
     }
 
-    Map<Processor, Pattern> wanted = new HashMap<>();
-    for (Processor processor : processorInfo.processors()) {
-      List<String> patterns = new ArrayList<>();
-      for (String supportedAnnotationType : processor.getSupportedAnnotationTypes()) {
-        // TODO(b/139026291): this handling of getSupportedAnnotationTypes isn't correct
-        patterns.add(supportedAnnotationType.replace("*", ".*"));
-      }
-      wanted.put(processor, Pattern.compile(Joiner.on('|').join(patterns)));
-    }
+    ImmutableMap<Processor, SupportedAnnotationTypes> wanted =
+        initializeSupportedAnnotationTypes(processorInfo);
 
     Set<ClassSymbol> allSymbols = new HashSet<>();
 
@@ -163,12 +161,14 @@
       }
       ImmutableSetMultimap<ClassSymbol, Symbol> allAnnotations = getAllAnnotations(env, syms);
       TurbineRoundEnvironment roundEnv = null;
-      for (Processor processor : processorInfo.processors()) {
+      for (Map.Entry<Processor, SupportedAnnotationTypes> e : wanted.entrySet()) {
+        Processor processor = e.getKey();
+        SupportedAnnotationTypes supportedAnnotationTypes = e.getValue();
         Set<TypeElement> annotations = new HashSet<>();
-        Pattern pattern = wanted.get(processor);
-        boolean run = toRun.contains(processor);
+        boolean run = supportedAnnotationTypes.everything() || toRun.contains(processor);
         for (ClassSymbol a : allAnnotations.keys()) {
-          if (pattern.matcher(a.toString()).matches()) {
+          if (supportedAnnotationTypes.everything()
+              || supportedAnnotationTypes.pattern().matcher(a.toString()).matches()) {
             annotations.add(factory.typeElement(a));
             run = true;
           }
@@ -184,7 +184,8 @@
             // TODO(cushon): consider disallowing this, or reporting a diagnostic
             processor.process(annotations, roundEnv);
           } catch (Throwable t) {
-            reportProcessorCrash(log, processor, t);
+            logProcessorCrash(log, processor, t);
+            return null;
           }
         }
       }
@@ -197,7 +198,7 @@
       }
       errorRaised = log.errorRaised();
       if (errorRaised) {
-        log.maybeThrow();
+        break;
       }
       log.clear();
       result =
@@ -228,7 +229,8 @@
       try (Timers.Timer unused = timers.start(processor)) {
         processor.process(ImmutableSet.of(), roundEnv);
       } catch (Throwable t) {
-        reportProcessorCrash(log, processor, t);
+        logProcessorCrash(log, processor, t);
+        return null;
       }
     }
 
@@ -249,7 +251,9 @@
               classpath,
               bootclasspath,
               moduleVersion);
-      log.maybeThrow();
+      if (log.anyErrors()) {
+        return null;
+      }
     }
 
     if (!filer.generatedClasses().isEmpty()) {
@@ -267,13 +271,44 @@
     return result;
   }
 
-  private static void reportProcessorCrash(TurbineLog log, Processor processor, Throwable t) {
+  private static ImmutableMap<Processor, SupportedAnnotationTypes>
+      initializeSupportedAnnotationTypes(ProcessorInfo processorInfo) {
+    ImmutableMap.Builder<Processor, SupportedAnnotationTypes> result = ImmutableMap.builder();
+    for (Processor processor : processorInfo.processors()) {
+      result.put(processor, SupportedAnnotationTypes.create(processor));
+    }
+    return result.build();
+  }
+
+  @AutoValue
+  abstract static class SupportedAnnotationTypes {
+
+    abstract boolean everything();
+
+    abstract Pattern pattern();
+
+    static SupportedAnnotationTypes create(Processor processor) {
+      List<String> patterns = new ArrayList<>();
+      boolean everything = false;
+      for (String supportedAnnotationType : processor.getSupportedAnnotationTypes()) {
+        if (supportedAnnotationType.equals("*")) {
+          everything = true;
+        } else {
+          // TODO(b/139026291): this handling of getSupportedAnnotationTypes isn't correct
+          patterns.add(supportedAnnotationType);
+        }
+      }
+      return new AutoValue_Processing_SupportedAnnotationTypes(
+          everything, Pattern.compile(Joiner.on('|').join(patterns)));
+    }
+  }
+
+  private static void logProcessorCrash(TurbineLog log, Processor processor, Throwable t) {
     log.diagnostic(
         Diagnostic.Kind.ERROR,
         String.format(
             "An exception occurred in %s:\n%s",
             processor.getClass().getCanonicalName(), Throwables.getStackTraceAsString(t)));
-    log.maybeThrow();
   }
 
   /** Returns a map from annotations present in the compilation to the annotated elements. */
@@ -313,7 +348,7 @@
   }
 
   // TODO(cushon): consider memoizing this (or isAnnotationInherited) if they show up in profiles
-  private static Set<ClassSymbol> inheritedAnnotations(
+  private static ImmutableSet<ClassSymbol> inheritedAnnotations(
       Set<ClassSymbol> seen, ClassSymbol sym, Env<ClassSymbol, TypeBoundClass> env) {
     ImmutableSet.Builder<ClassSymbol> result = ImmutableSet.builder();
     ClassSymbol curr = sym;
@@ -360,87 +395,110 @@
 
   public static ProcessorInfo initializeProcessors(
       ImmutableList<String> javacopts,
-      ImmutableList<String> processorPath,
       ImmutableSet<String> processorNames,
-      ImmutableSet<String> builtinProcessors)
-      throws MalformedURLException {
-    ClassLoader processorLoader = null;
-    ImmutableList.Builder<Processor> processors = ImmutableList.builder();
-    ImmutableMap<String, String> processorOptions;
-    if (!processorNames.isEmpty() && !javacopts.contains("-proc:none")) {
-      if (!processorPath.isEmpty()) {
-        processorLoader =
-            new URLClassLoader(
-                toUrls(processorPath),
-                new ClassLoader(getPlatformClassLoader()) {
-                  @Override
-                  protected Class<?> findClass(String name) throws ClassNotFoundException {
-                    if (name.startsWith("com.sun.source.")
-                        || name.startsWith("com.sun.tools.")
-                        || name.startsWith("com.google.common.collect.")
-                        || name.startsWith("com.google.common.base.")
-                        || name.startsWith("com.google.common.graph.")
-                        || name.startsWith("com.google.devtools.build.buildjar.javac.statistics.")
-                        || name.startsWith("dagger.model.")
-                        || name.startsWith("dagger.spi.")
-                        || name.equals("com.google.turbine.processing.TurbineProcessingEnvironment")
-                        || builtinProcessors.contains(name)) {
-                      return Class.forName(name);
-                    }
-                    throw new ClassNotFoundException(name);
-                  }
-                });
-      } else {
-        processorLoader = Processing.class.getClassLoader();
-      }
-      for (String processor : processorNames) {
-        try {
-          Class<? extends Processor> clazz =
-              Class.forName(processor, false, processorLoader).asSubclass(Processor.class);
-          processors.add(clazz.getConstructor().newInstance());
-        } catch (ReflectiveOperationException e) {
-          throw new LinkageError(e.getMessage(), e);
-        }
-      }
-      processorOptions = processorOptions(javacopts);
-    } else {
-      processorOptions = ImmutableMap.of();
+      ClassLoader processorLoader) {
+    if (processorNames.isEmpty() || javacopts.contains("-proc:none")) {
+      return ProcessorInfo.empty();
     }
+    ImmutableList<Processor> processors = instantiateProcessors(processorNames, processorLoader);
+    ImmutableMap<String, String> processorOptions = processorOptions(javacopts);
+    SourceVersion sourceVersion = parseSourceVersion(javacopts);
+    return ProcessorInfo.create(processors, processorLoader, processorOptions, sourceVersion);
+  }
+
+  private static ImmutableList<Processor> instantiateProcessors(
+      ImmutableSet<String> processorNames, ClassLoader processorLoader) {
+    ImmutableList.Builder<Processor> processors = ImmutableList.builder();
+    for (String processor : processorNames) {
+      try {
+        Class<? extends Processor> clazz =
+            Class.forName(processor, false, processorLoader).asSubclass(Processor.class);
+        processors.add(clazz.getConstructor().newInstance());
+      } catch (ReflectiveOperationException e) {
+        throw new LinkageError(e.getMessage(), e);
+      }
+    }
+    return processors.build();
+  }
+
+  public static ClassLoader processorLoader(
+      ImmutableList<String> processorPath, ImmutableSet<String> builtinProcessors)
+      throws MalformedURLException {
+    if (processorPath.isEmpty()) {
+      return Processing.class.getClassLoader();
+    }
+    return new URLClassLoader(
+        toUrls(processorPath),
+        new ClassLoader(getPlatformClassLoader()) {
+          @Override
+          protected Class<?> findClass(String name) throws ClassNotFoundException {
+            if (name.equals("com.google.turbine.processing.TurbineProcessingEnvironment")) {
+              return Class.forName(name);
+            }
+            if (!builtinProcessors.isEmpty()) {
+              if (name.startsWith("com.sun.source.")
+                  || name.startsWith("com.sun.tools.")
+                  || name.startsWith("com.google.common.collect.")
+                  || name.startsWith("com.google.common.base.")
+                  || name.startsWith("com.google.common.graph.")
+                  || name.startsWith("com.google.devtools.build.buildjar.javac.statistics.")
+                  || name.startsWith("dagger.model.")
+                  || name.startsWith("dagger.spi.")
+                  || builtinProcessors.contains(name)) {
+                return Class.forName(name);
+              }
+            }
+            throw new ClassNotFoundException(name);
+          }
+        });
+  }
+
+  @VisibleForTesting
+  static SourceVersion parseSourceVersion(ImmutableList<String> javacopts) {
     SourceVersion sourceVersion = SourceVersion.latestSupported();
     Iterator<String> it = javacopts.iterator();
     while (it.hasNext()) {
       String option = it.next();
       switch (option) {
-        case "-target":
-          if (it.hasNext()) {
-            String value = it.next();
-            switch (value) {
-              case "5":
-              case "1.5":
-                sourceVersion = SourceVersion.RELEASE_5;
-                break;
-              case "6":
-              case "1.6":
-                sourceVersion = SourceVersion.RELEASE_6;
-                break;
-              case "7":
-              case "1.7":
-                sourceVersion = SourceVersion.RELEASE_7;
-                break;
-              case "8":
-                sourceVersion = SourceVersion.RELEASE_8;
-                break;
-              default:
-                break;
-            }
+        case "-source":
+          if (!it.hasNext()) {
+            throw new IllegalArgumentException("-source requires an argument");
           }
+          sourceVersion = parseSourceVersion(it.next());
           break;
         default:
           break;
       }
     }
-    return ProcessorInfo.create(
-        processors.build(), processorLoader, processorOptions, sourceVersion);
+    return sourceVersion;
+  }
+
+  private static SourceVersion parseSourceVersion(String value) {
+    boolean hasPrefix = value.startsWith("1.");
+    Integer version = Ints.tryParse(hasPrefix ? value.substring("1.".length()) : value);
+    if (!isValidSourceVersion(version, hasPrefix)) {
+      throw new IllegalArgumentException("invalid -source version: " + value);
+    }
+    try {
+      return SourceVersion.valueOf("RELEASE_" + version);
+    } catch (IllegalArgumentException unused) {
+      throw new IllegalArgumentException("invalid -source version: " + value);
+    }
+  }
+
+  private static boolean isValidSourceVersion(Integer version, boolean hasPrefix) {
+    if (version == null) {
+      return false;
+    }
+    if (version < 5) {
+      // the earliest source version supported by JDK 8 is Java 5
+      return false;
+    }
+    if (hasPrefix && version > 10) {
+      // javac supports legacy `1.*` version numbers for source versions up to Java 10
+      return false;
+    }
+    return true;
   }
 
   private static URL[] toUrls(ImmutableList<String> processorPath) throws MalformedURLException {
@@ -548,9 +606,12 @@
     ImmutableMap<String, Duration> build() {
       ImmutableMap.Builder<String, Duration> result = ImmutableMap.builder();
       for (Map.Entry<Class<?>, Stopwatch> e : processorTimers.entrySet()) {
-        result.put(e.getKey().getCanonicalName(), e.getValue().elapsed());
+        // requireNonNull is safe, barring bizarre processor implementations (e.g., anonymous class)
+        result.put(requireNonNull(e.getKey().getCanonicalName()), e.getValue().elapsed());
       }
       return result.build();
     }
   }
+
+  private Processing() {}
 }
diff --git a/java/com/google/turbine/binder/Resolve.java b/java/com/google/turbine/binder/Resolve.java
index 28a8be3..66e1036 100644
--- a/java/com/google/turbine/binder/Resolve.java
+++ b/java/com/google/turbine/binder/Resolve.java
@@ -33,7 +33,7 @@
 import java.util.Set;
 
 /** Qualified name resolution. */
-public class Resolve {
+public final class Resolve {
 
   /**
    * Performs JLS 6.5.5.2 qualified type name resolution of a type with the given simple name,
@@ -213,4 +213,6 @@
     }
     throw new AssertionError(visibility);
   }
+
+  private Resolve() {}
 }
diff --git a/java/com/google/turbine/binder/TypeBinder.java b/java/com/google/turbine/binder/TypeBinder.java
index 7b01856..a28acd9 100644
--- a/java/com/google/turbine/binder/TypeBinder.java
+++ b/java/com/google/turbine/binder/TypeBinder.java
@@ -16,6 +16,8 @@
 
 package com.google.turbine.binder;
 
+import static java.util.Objects.requireNonNull;
+
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -54,6 +56,7 @@
 import com.google.turbine.type.AnnoInfo;
 import com.google.turbine.type.Type;
 import com.google.turbine.type.Type.IntersectionTy;
+import com.google.turbine.types.Deannotate;
 import java.util.ArrayDeque;
 import java.util.ArrayList;
 import java.util.HashSet;
@@ -394,7 +397,8 @@
       ImmutableList<Tree.TyParam> trees, CompoundScope scope, Map<String, TyVarSymbol> symbols) {
     ImmutableMap.Builder<TyVarSymbol, TyVarInfo> result = ImmutableMap.builder();
     for (Tree.TyParam tree : trees) {
-      TyVarSymbol sym = symbols.get(tree.name().value());
+      // `symbols` is constructed to guarantee the requireNonNull call is safe.
+      TyVarSymbol sym = requireNonNull(symbols.get(tree.name().value()));
       ImmutableList.Builder<Type> bounds = ImmutableList.builder();
       for (Tree bound : tree.bounds()) {
         bounds.add(bindTy(scope, bound));
@@ -493,6 +497,9 @@
             == 0) {
           access |= TurbineFlag.ACC_ABSTRACT;
         }
+        if ((access & TurbineFlag.ACC_FINAL) == TurbineFlag.ACC_FINAL) {
+          log.error(t.position(), ErrorKind.UNEXPECTED_MODIFIER, TurbineModifier.FINAL);
+        }
         break;
       case ENUM:
         if (name.equals("<init>")) {
@@ -618,7 +625,14 @@
       case WILD_TY:
         return bindWildTy(scope, (Tree.WildTy) ty);
       default:
-        return bindTy(scope, ty);
+        Type result = bindTy(scope, ty);
+        if (result.tyKind().equals(Type.TyKind.PRIM_TY)) {
+          // Omit type annotations when printing the type in the diagnostic, since they're
+          // irrelevant and could be invalid if there were deferred errors.
+          // TODO(cushon): consider ensuring this is done for all diagnostics that mention types
+          log.error(ty.position(), ErrorKind.UNEXPECTED_TYPE, Deannotate.deannotate(result));
+        }
+        return result;
     }
   }
 
diff --git a/java/com/google/turbine/binder/bound/AnnotationMetadata.java b/java/com/google/turbine/binder/bound/AnnotationMetadata.java
index 31860b6..a4d3037 100644
--- a/java/com/google/turbine/binder/bound/AnnotationMetadata.java
+++ b/java/com/google/turbine/binder/bound/AnnotationMetadata.java
@@ -25,7 +25,7 @@
 import java.util.EnumSet;
 
 /**
- * Annotation metadata, e.g. from {@link @java.lang.annotation.Target}, {@link
+ * Annotation metadata, e.g. from {@link java.lang.annotation.Target}, {@link
  * java.lang.annotation.Retention}, and {@link java.lang.annotation.Repeatable}.
  */
 public class AnnotationMetadata {
diff --git a/java/com/google/turbine/binder/bound/HeaderBoundClass.java b/java/com/google/turbine/binder/bound/HeaderBoundClass.java
index 14807bb..7aeb3d8 100644
--- a/java/com/google/turbine/binder/bound/HeaderBoundClass.java
+++ b/java/com/google/turbine/binder/bound/HeaderBoundClass.java
@@ -30,5 +30,5 @@
   ImmutableList<ClassSymbol> interfaces();
 
   /** Declared type parameters. */
-  public ImmutableMap<String, TyVarSymbol> typeParameters();
+  ImmutableMap<String, TyVarSymbol> typeParameters();
 }
diff --git a/java/com/google/turbine/binder/bound/TypeBoundClass.java b/java/com/google/turbine/binder/bound/TypeBoundClass.java
index e8933ac..99d15bb 100644
--- a/java/com/google/turbine/binder/bound/TypeBoundClass.java
+++ b/java/com/google/turbine/binder/bound/TypeBoundClass.java
@@ -51,7 +51,7 @@
   ImmutableList<MethodInfo> methods();
 
   /**
-   * Annotation metadata, e.g. from {@link @java.lang.annotation.Target}, {@link
+   * Annotation metadata, e.g. from {@link java.lang.annotation.Target}, {@link
    * java.lang.annotation.Retention}, and {@link java.lang.annotation.Repeatable}.
    */
   AnnotationMetadata annotationMetadata();
@@ -229,6 +229,14 @@
       return defaultValue;
     }
 
+    /**
+     * Returns true for annotation members with a default value. The default value may not have been
+     * bound yet, in which case {@link #defaultValue} may still return {@code null}.
+     */
+    public boolean hasDefaultValue() {
+      return decl() != null ? decl().defaultValue().isPresent() : defaultValue() != null;
+    }
+
     /** The declaration. */
     public MethDecl decl() {
       return decl;
diff --git a/java/com/google/turbine/binder/bytecode/BytecodeBinder.java b/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
index 66d4cf0..0f4bac1 100644
--- a/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
+++ b/java/com/google/turbine/binder/bytecode/BytecodeBinder.java
@@ -49,7 +49,7 @@
 import java.util.function.Supplier;
 
 /** Bind {@link Type}s from bytecode. */
-public class BytecodeBinder {
+public final class BytecodeBinder {
 
   static Type.ClassTy bindClassTy(Sig.ClassTySig sig, Function<String, TyVarSymbol> scope) {
     StringBuilder sb = new StringBuilder(sig.pkg());
@@ -212,4 +212,6 @@
         /* uses= */ ImmutableList.of(),
         /* provides= */ ImmutableList.of());
   }
+
+  private BytecodeBinder() {}
 }
diff --git a/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java b/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java
index b992643..82cefc1 100644
--- a/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java
+++ b/java/com/google/turbine/binder/bytecode/BytecodeBoundClass.java
@@ -18,6 +18,7 @@
 
 import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Verify.verify;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Supplier;
 import com.google.common.base.Suppliers;
@@ -25,8 +26,6 @@
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.ImmutableSet;
 import com.google.turbine.binder.bound.AnnotationMetadata;
-import com.google.turbine.binder.bound.BoundClass;
-import com.google.turbine.binder.bound.HeaderBoundClass;
 import com.google.turbine.binder.bound.TypeBoundClass;
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.sym.ClassSymbol;
@@ -69,18 +68,18 @@
  * resolved and canonicalized so there are no cycles. The laziness also minimizes the amount of work
  * done on the classpath.
  */
-public class BytecodeBoundClass implements BoundClass, HeaderBoundClass, TypeBoundClass {
+public class BytecodeBoundClass implements TypeBoundClass {
 
   private final ClassSymbol sym;
   private final Env<ClassSymbol, BytecodeBoundClass> env;
   private final Supplier<ClassFile> classFile;
-  private final String jarFile;
+  private final @Nullable String jarFile;
 
   public BytecodeBoundClass(
       ClassSymbol sym,
       Supplier<byte[]> bytes,
       Env<ClassSymbol, BytecodeBoundClass> env,
-      String jarFile) {
+      @Nullable String jarFile) {
     this.sym = sym;
     this.env = env;
     this.jarFile = jarFile;
@@ -124,11 +123,11 @@
     return kind.get();
   }
 
-  private final Supplier<ClassSymbol> owner =
+  private final Supplier<@Nullable ClassSymbol> owner =
       Suppliers.memoize(
-          new Supplier<ClassSymbol>() {
+          new Supplier<@Nullable ClassSymbol>() {
             @Override
-            public ClassSymbol get() {
+            public @Nullable ClassSymbol get() {
               for (ClassFile.InnerClass inner : classFile.get().innerClasses()) {
                 if (sym.binaryName().equals(inner.innerClass())) {
                   return new ClassSymbol(inner.outerClass());
@@ -188,11 +187,11 @@
     return access.get();
   }
 
-  private final Supplier<ClassSig> sig =
+  private final Supplier<@Nullable ClassSig> sig =
       Suppliers.memoize(
-          new Supplier<ClassSig>() {
+          new Supplier<@Nullable ClassSig>() {
             @Override
-            public ClassSig get() {
+            public @Nullable ClassSig get() {
               String signature = classFile.get().signature();
               if (signature == null) {
                 return null;
@@ -223,11 +222,11 @@
     return tyParams.get();
   }
 
-  private final Supplier<ClassSymbol> superclass =
+  private final Supplier<@Nullable ClassSymbol> superclass =
       Suppliers.memoize(
-          new Supplier<ClassSymbol>() {
+          new Supplier<@Nullable ClassSymbol>() {
             @Override
-            public ClassSymbol get() {
+            public @Nullable ClassSymbol get() {
               String superclass = classFile.get().superName();
               if (superclass == null) {
                 return null;
@@ -237,7 +236,7 @@
           });
 
   @Override
-  public ClassSymbol superclass() {
+  public @Nullable ClassSymbol superclass() {
     return superclass.get();
   }
 
@@ -259,11 +258,11 @@
     return interfaces.get();
   }
 
-  private final Supplier<ClassTy> superClassType =
+  private final Supplier<@Nullable ClassTy> superClassType =
       Suppliers.memoize(
-          new Supplier<ClassTy>() {
+          new Supplier<@Nullable ClassTy>() {
             @Override
-            public ClassTy get() {
+            public @Nullable ClassTy get() {
               if (superclass() == null) {
                 return null;
               }
@@ -276,7 +275,7 @@
           });
 
   @Override
-  public ClassTy superClassType() {
+  public @Nullable ClassTy superClassType() {
     return superClassType.get();
   }
 
@@ -319,7 +318,8 @@
               ImmutableMap.Builder<TyVarSymbol, TyVarInfo> tparams = ImmutableMap.builder();
               Function<String, TyVarSymbol> scope = makeScope(env, sym, typeParameters());
               for (Sig.TyParamSig p : sig.get().tyParams()) {
-                tparams.put(typeParameters().get(p.name()), bindTyParam(p, scope));
+                // typeParameters() is constructed to guarantee the requireNonNull call is safe.
+                tparams.put(requireNonNull(typeParameters().get(p.name())), bindTyParam(p, scope));
               }
               return tparams.build();
             }
@@ -380,14 +380,19 @@
             public ImmutableList<MethodInfo> get() {
               ImmutableList.Builder<MethodInfo> methods = ImmutableList.builder();
               int idx = 0;
-              for (ClassFile.MethodInfo m : classFile.get().methods()) {
-                methods.add(bindMethod(idx++, m));
+              ClassFile cf = classFile.get();
+              for (ClassFile.MethodInfo m : cf.methods()) {
+                if (m.name().equals("<clinit>")) {
+                  // Don't bother reading class initializers, which we don't need
+                  continue;
+                }
+                methods.add(bindMethod(cf, idx++, m));
               }
               return methods.build();
             }
           });
 
-  private MethodInfo bindMethod(int methodIdx, ClassFile.MethodInfo m) {
+  private MethodInfo bindMethod(ClassFile classFile, int methodIdx, ClassFile.MethodInfo m) {
     MethodSymbol methodSymbol = new MethodSymbol(methodIdx, sym, m.name());
     Sig.MethodSig sig = new SigParser(firstNonNull(m.signature(), m.descriptor())).parseMethodSig();
 
@@ -405,7 +410,8 @@
       ImmutableMap.Builder<TyVarSymbol, TyVarInfo> tparams = ImmutableMap.builder();
       Function<String, TyVarSymbol> scope = makeScope(env, sym, tyParams);
       for (Sig.TyParamSig p : sig.tyParams()) {
-        tparams.put(tyParams.get(p.name()), bindTyParam(p, scope));
+        // tyParams is constructed to guarantee the requireNonNull call is safe.
+        tparams.put(requireNonNull(tyParams.get(p.name())), bindTyParam(p, scope));
       }
       tyParamTypes = tparams.build();
     }
@@ -460,13 +466,19 @@
 
     ImmutableList<AnnoInfo> annotations = BytecodeBinder.bindAnnotations(m.annotations());
 
+    int access = m.access();
+    if (((classFile.access() & TurbineFlag.ACC_INTERFACE) == TurbineFlag.ACC_INTERFACE)
+        && (access & (TurbineFlag.ACC_ABSTRACT | TurbineFlag.ACC_STATIC)) == 0) {
+      access |= TurbineFlag.ACC_DEFAULT;
+    }
+
     return new MethodInfo(
         methodSymbol,
         tyParamTypes,
         ret,
         formals.build(),
         exceptions.build(),
-        m.access(),
+        access,
         defaultValue,
         /* decl= */ null,
         annotations,
@@ -478,11 +490,11 @@
     return methods.get();
   }
 
-  private final Supplier<AnnotationMetadata> annotationMetadata =
+  private final Supplier<@Nullable AnnotationMetadata> annotationMetadata =
       Suppliers.memoize(
-          new Supplier<AnnotationMetadata>() {
+          new Supplier<@Nullable AnnotationMetadata>() {
             @Override
-            public AnnotationMetadata get() {
+            public @Nullable AnnotationMetadata get() {
               if ((access() & TurbineFlag.ACC_ANNOTATION) != TurbineFlag.ACC_ANNOTATION) {
                 return null;
               }
@@ -508,8 +520,11 @@
             }
           });
 
-  private static RetentionPolicy bindRetention(AnnotationInfo annotation) {
+  private static @Nullable RetentionPolicy bindRetention(AnnotationInfo annotation) {
     ElementValue val = annotation.elementValuePairs().get("value");
+    if (val == null) {
+      return null;
+    }
     if (val.kind() != Kind.ENUM) {
       return null;
     }
@@ -523,6 +538,7 @@
   private static ImmutableSet<TurbineElementType> bindTarget(AnnotationInfo annotation) {
     ImmutableSet.Builder<TurbineElementType> result = ImmutableSet.builder();
     ElementValue val = annotation.elementValuePairs().get("value");
+    requireNonNull(val);
     switch (val.kind()) {
       case ARRAY:
         for (ElementValue element : ((ArrayValue) val).elements()) {
@@ -547,8 +563,11 @@
     }
   }
 
-  private static ClassSymbol bindRepeatable(AnnotationInfo annotation) {
+  private static @Nullable ClassSymbol bindRepeatable(AnnotationInfo annotation) {
     ElementValue val = annotation.elementValuePairs().get("value");
+    if (val == null) {
+      return null;
+    }
     switch (val.kind()) {
       case CLASS:
         String className = ((ConstTurbineClassValue) val).className();
@@ -560,7 +579,7 @@
   }
 
   @Override
-  public AnnotationMetadata annotationMetadata() {
+  public @Nullable AnnotationMetadata annotationMetadata() {
     return annotationMetadata.get();
   }
 
@@ -611,7 +630,11 @@
   }
 
   /** The jar file the symbol was loaded from. */
-  public String jarFile() {
+  public @Nullable String jarFile() {
+    String transitiveJar = classFile.get().transitiveJar();
+    if (transitiveJar != null) {
+      return transitiveJar;
+    }
     return jarFile;
   }
 
diff --git a/java/com/google/turbine/binder/bytecode/package-info.java b/java/com/google/turbine/binder/bytecode/package-info.java
new file mode 100644
index 0000000..23c59f0
--- /dev/null
+++ b/java/com/google/turbine/binder/bytecode/package-info.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2016 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder.bytecode;
diff --git a/java/com/google/turbine/binder/env/Env.java b/java/com/google/turbine/binder/env/Env.java
index 6ee38a4..a78d3e6 100644
--- a/java/com/google/turbine/binder/env/Env.java
+++ b/java/com/google/turbine/binder/env/Env.java
@@ -20,10 +20,10 @@
 import com.google.turbine.binder.sym.Symbol;
 
 /**
- * An environment that maps {@link Symbols} {@code S} to bound nodes {@code V}.
+ * An environment that maps {@link Symbol}s {@code S} to bound nodes {@code V}.
  *
- * <p>For example, {@link BoundClass} represents superclasses as a {@link ClassSymbol}, which only
- * contains the binary name of the type. To get the {@link BoundClass} for that supertype, an {@code
+ * <p>For example, {@code BoundClass} represents superclasses as a {@link ClassSymbol}, which only
+ * contains the binary name of the type. To get the {@code BoundClass} for that supertype, an {@code
  * Env<BoundClass>} is used.
  *
  * <p>The indirection through env makes it possible to represent a graph with cycles using immutable
diff --git a/java/com/google/turbine/binder/env/LazyEnv.java b/java/com/google/turbine/binder/env/LazyEnv.java
index 9e8afd5..a9c3bd1 100644
--- a/java/com/google/turbine/binder/env/LazyEnv.java
+++ b/java/com/google/turbine/binder/env/LazyEnv.java
@@ -27,17 +27,17 @@
  * An env that permits an analysis pass to access information about symbols from the current pass,
  * recursively. Cycles are detected, and result in an {@link LazyBindingError} being thrown.
  *
- * <p>This is used primarily for resolving the supertype hierarchy in {@link HierarchyBinder}. The
- * supertype hierarchy forms a directed acyclic graph, and {@link HierarchyBinder} needs to process
+ * <p>This is used primarily for resolving the supertype hierarchy in {@code HierarchyBinder}. The
+ * supertype hierarchy forms a directed acyclic graph, and {@code HierarchyBinder} needs to process
  * classes in a topological sort order of that graph. Unfortuntately, we can't produce a suitable
  * sort order until the graph exists.
  *
  * @param <T> the interface type of the bound node {@link V}, shared by any underlying environments.
- * @param <V> a specific implementation of {@code T}. For example, during hierarchy binding {@link
+ * @param <V> a specific implementation of {@code T}. For example, during hierarchy binding {@code
  *     SourceHeaderBoundClass} nodes are being completed from the sources being compiled, and the
- *     analysis of a given symbol may require looking up {@link HeaderBoundClass} nodes that will
- *     either be backed by other {@link SourceHeaderBoundClass} nodes or {@link BytecodeBoundClass}
- *     nodes. So the phase uses an {@link LazyEnv<HeaderBoundClass, SourceHeaderBoundClass>}.
+ *     analysis of a given symbol may require looking up {@code HeaderBoundClass} nodes that will
+ *     either be backed by other {@code SourceHeaderBoundClass} nodes or {@code BytecodeBoundClass}
+ *     nodes. So the phase uses an {@code LazyEnv<HeaderBoundClass, SourceHeaderBoundClass>}.
  */
 public class LazyEnv<S extends Symbol, T, V extends T> implements Env<S, V> {
 
diff --git a/java/com/google/turbine/bytecode/AnnotationWriter.java b/java/com/google/turbine/bytecode/AnnotationWriter.java
index b547971..34d6262 100644
--- a/java/com/google/turbine/bytecode/AnnotationWriter.java
+++ b/java/com/google/turbine/bytecode/AnnotationWriter.java
@@ -34,7 +34,7 @@
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo.TypeParameterTarget;
 import com.google.turbine.bytecode.ClassFile.TypeAnnotationInfo.TypePath;
 import com.google.turbine.model.Const.Value;
-import java.util.Map.Entry;
+import java.util.Map;
 
 /** Writes an {@link AnnotationInfo} to a class file. */
 public class AnnotationWriter {
@@ -50,7 +50,7 @@
   public void writeAnnotation(AnnotationInfo annotation) {
     output.writeShort(pool.utf8(annotation.typeName()));
     output.writeShort(annotation.elementValuePairs().size());
-    for (Entry<String, ElementValue> entry : annotation.elementValuePairs().entrySet()) {
+    for (Map.Entry<String, ElementValue> entry : annotation.elementValuePairs().entrySet()) {
       output.writeShort(pool.utf8(entry.getKey()));
       writeElementValue(entry.getValue());
     }
diff --git a/java/com/google/turbine/bytecode/Attribute.java b/java/com/google/turbine/bytecode/Attribute.java
index 29efb60..7b415a7 100644
--- a/java/com/google/turbine/bytecode/Attribute.java
+++ b/java/com/google/turbine/bytecode/Attribute.java
@@ -41,7 +41,8 @@
     RUNTIME_VISIBLE_TYPE_ANNOTATIONS("RuntimeVisibleTypeAnnotations"),
     RUNTIME_INVISIBLE_TYPE_ANNOTATIONS("RuntimeInvisibleTypeAnnotations"),
     METHOD_PARAMETERS("MethodParameters"),
-    MODULE("Module");
+    MODULE("Module"),
+    TURBINE_TRANSITIVE_JAR("TurbineTransitiveJar");
 
     private final String signature;
 
@@ -309,4 +310,19 @@
       return module;
     }
   }
+
+  /** A custom attribute for recording the original jar of repackaged transitive classes. */
+  class TurbineTransitiveJar implements Attribute {
+
+    final String transitiveJar;
+
+    public TurbineTransitiveJar(String transitiveJar) {
+      this.transitiveJar = transitiveJar;
+    }
+
+    @Override
+    public Kind kind() {
+      return Kind.TURBINE_TRANSITIVE_JAR;
+    }
+  }
 }
diff --git a/java/com/google/turbine/bytecode/AttributeWriter.java b/java/com/google/turbine/bytecode/AttributeWriter.java
index c5ffd16..84ca55f 100644
--- a/java/com/google/turbine/bytecode/AttributeWriter.java
+++ b/java/com/google/turbine/bytecode/AttributeWriter.java
@@ -24,6 +24,7 @@
 import com.google.turbine.bytecode.Attribute.InnerClasses;
 import com.google.turbine.bytecode.Attribute.MethodParameters;
 import com.google.turbine.bytecode.Attribute.Signature;
+import com.google.turbine.bytecode.Attribute.TurbineTransitiveJar;
 import com.google.turbine.bytecode.Attribute.TypeAnnotations;
 import com.google.turbine.bytecode.ClassFile.AnnotationInfo;
 import com.google.turbine.bytecode.ClassFile.MethodInfo.ParameterInfo;
@@ -87,6 +88,9 @@
       case MODULE:
         writeModule((Attribute.Module) attribute);
         break;
+      case TURBINE_TRANSITIVE_JAR:
+        writeTurbineTransitiveJar((Attribute.TurbineTransitiveJar) attribute);
+        break;
     }
   }
 
@@ -266,4 +270,10 @@
     output.writeInt(data.length);
     output.write(data);
   }
+
+  private void writeTurbineTransitiveJar(TurbineTransitiveJar attribute) {
+    output.writeShort(pool.utf8(attribute.kind().signature()));
+    output.writeInt(2);
+    output.writeShort(pool.utf8(attribute.transitiveJar));
+  }
 }
diff --git a/java/com/google/turbine/bytecode/ClassFile.java b/java/com/google/turbine/bytecode/ClassFile.java
index 8ee2aac..e979edc 100644
--- a/java/com/google/turbine/bytecode/ClassFile.java
+++ b/java/com/google/turbine/bytecode/ClassFile.java
@@ -42,6 +42,7 @@
   private final List<InnerClass> innerClasses;
   private final ImmutableList<TypeAnnotationInfo> typeAnnotations;
   @Nullable private final ModuleInfo module;
+  @Nullable private final String transitiveJar;
 
   public ClassFile(
       int access,
@@ -54,7 +55,8 @@
       List<AnnotationInfo> annotations,
       List<InnerClass> innerClasses,
       ImmutableList<TypeAnnotationInfo> typeAnnotations,
-      @Nullable ModuleInfo module) {
+      @Nullable ModuleInfo module,
+      @Nullable String transitiveJar) {
     this.access = access;
     this.name = name;
     this.signature = signature;
@@ -66,6 +68,7 @@
     this.innerClasses = innerClasses;
     this.typeAnnotations = typeAnnotations;
     this.module = module;
+    this.transitiveJar = transitiveJar;
   }
 
   /** Class access and property flags. */
@@ -124,6 +127,12 @@
     return module;
   }
 
+  /** The original jar of a repackaged transitive class. */
+  @Nullable
+  public String transitiveJar() {
+    return transitiveJar;
+  }
+
   /** The contents of a JVMS §4.5 field_info structure. */
   public static class FieldInfo {
 
diff --git a/java/com/google/turbine/bytecode/ClassReader.java b/java/com/google/turbine/bytecode/ClassReader.java
index 9c79b42..ac8b1e1 100644
--- a/java/com/google/turbine/bytecode/ClassReader.java
+++ b/java/com/google/turbine/bytecode/ClassReader.java
@@ -106,6 +106,7 @@
     List<ClassFile.InnerClass> innerclasses = ImmutableList.of();
     ImmutableList.Builder<ClassFile.AnnotationInfo> annotations = ImmutableList.builder();
     ClassFile.ModuleInfo module = null;
+    String transitiveJar = null;
     int attributesCount = reader.u2();
     for (int j = 0; j < attributesCount; j++) {
       int attributeNameIndex = reader.u2();
@@ -124,6 +125,9 @@
         case "Module":
           module = readModule(constantPool);
           break;
+        case "TurbineTransitiveJar":
+          transitiveJar = readTurbineTransitiveJar(constantPool);
+          break;
         default:
           reader.skip(reader.u4());
           break;
@@ -141,7 +145,8 @@
         annotations.build(),
         innerclasses,
         ImmutableList.of(),
-        module);
+        module,
+        transitiveJar);
   }
 
   /** Reads a JVMS 4.7.9 Signature attribute. */
@@ -509,4 +514,9 @@
     }
     return fields;
   }
+
+  private String readTurbineTransitiveJar(ConstantPoolReader constantPool) {
+    reader.u4(); // length
+    return constantPool.utf8(reader.u2());
+  }
 }
diff --git a/java/com/google/turbine/bytecode/ClassWriter.java b/java/com/google/turbine/bytecode/ClassWriter.java
index c3490ca..de975f2 100644
--- a/java/com/google/turbine/bytecode/ClassWriter.java
+++ b/java/com/google/turbine/bytecode/ClassWriter.java
@@ -27,7 +27,7 @@
 import java.util.List;
 
 /** Class file writing. */
-public class ClassWriter {
+public final class ClassWriter {
 
   private static final int MAGIC = 0xcafebabe;
   private static final int MINOR_VERSION = 0;
@@ -124,4 +124,6 @@
     result.write(body.toByteArray());
     return result.toByteArray();
   }
+
+  private ClassWriter() {}
 }
diff --git a/java/com/google/turbine/bytecode/LowerAttributes.java b/java/com/google/turbine/bytecode/LowerAttributes.java
index 67ef2b4..5ae42af 100644
--- a/java/com/google/turbine/bytecode/LowerAttributes.java
+++ b/java/com/google/turbine/bytecode/LowerAttributes.java
@@ -29,7 +29,7 @@
 import java.util.List;
 
 /** Lower information in {@link ClassFile} structures to attributes. */
-public class LowerAttributes {
+public final class LowerAttributes {
 
   /** Collects the {@link Attribute}s for a {@link ClassFile}. */
   static List<Attribute> classAttributes(ClassFile classfile) {
@@ -45,6 +45,9 @@
     if (classfile.module() != null) {
       attributes.add(new Attribute.Module(classfile.module()));
     }
+    if (classfile.transitiveJar() != null) {
+      attributes.add(new Attribute.TurbineTransitiveJar(classfile.transitiveJar()));
+    }
     return attributes;
   }
 
@@ -146,4 +149,6 @@
       attributes.add(new Attribute.RuntimeInvisibleParameterAnnotations(invisibles));
     }
   }
+
+  private LowerAttributes() {}
 }
diff --git a/java/com/google/turbine/bytecode/sig/Sig.java b/java/com/google/turbine/bytecode/sig/Sig.java
index e85740f..f759269 100644
--- a/java/com/google/turbine/bytecode/sig/Sig.java
+++ b/java/com/google/turbine/bytecode/sig/Sig.java
@@ -21,7 +21,7 @@
 import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** JVMS 4.7.9.1 signatures. */
-public class Sig {
+public final class Sig {
 
   /** A JVMS 4.7.9.1 ClassSignature. */
   public static class ClassSig {
@@ -343,4 +343,6 @@
       return exceptions;
     }
   }
+
+  private Sig() {}
 }
diff --git a/java/com/google/turbine/deps/Dependencies.java b/java/com/google/turbine/deps/Dependencies.java
index 92193e8..ef1eea9 100644
--- a/java/com/google/turbine/deps/Dependencies.java
+++ b/java/com/google/turbine/deps/Dependencies.java
@@ -51,7 +51,7 @@
 import java.util.Set;
 
 /** Support for Bazel jdeps dependency output. */
-public class Dependencies {
+public final class Dependencies {
   /** Creates a jdeps proto for the current compilation. */
   public static DepsProto.Dependencies collectDeps(
       Optional<String> targetLabel, ClassPath bootclasspath, BindingResult bound, Lowered lowered) {
@@ -219,4 +219,6 @@
     // preserve the order of entries in the transitive classpath
     return Collections2.filter(transitiveClasspath, Predicates.in(reduced));
   }
+
+  private Dependencies() {}
 }
diff --git a/java/com/google/turbine/deps/Transitive.java b/java/com/google/turbine/deps/Transitive.java
index 8b0d44d..75d23f6 100644
--- a/java/com/google/turbine/deps/Transitive.java
+++ b/java/com/google/turbine/deps/Transitive.java
@@ -33,13 +33,14 @@
 import com.google.turbine.model.TurbineFlag;
 import java.util.LinkedHashSet;
 import java.util.Set;
+import org.checkerframework.checker.nullness.qual.Nullable;
 
 /**
  * Collects the minimal compile-time API for symbols in the supertype closure of compiled classes.
  * This allows header compilations to be performed against a classpath containing only direct
  * dependencies and no transitive dependencies.
  */
-public class Transitive {
+public final class Transitive {
 
   public static ImmutableMap<String, byte[]> collectDeps(
       ClassPath bootClassPath, BindingResult bound) {
@@ -54,7 +55,8 @@
         // don't export symbols loaded from the bootclasspath
         continue;
       }
-      transitive.put(sym.binaryName(), ClassWriter.writeClass(trimClass(info.classFile())));
+      transitive.put(
+          sym.binaryName(), ClassWriter.writeClass(trimClass(info.classFile(), info.jarFile())));
     }
     return transitive.build();
   }
@@ -62,7 +64,7 @@
   /**
    * Removes information from repackaged classes that will not be needed by upstream compilations.
    */
-  public static ClassFile trimClass(ClassFile cf) {
+  public static ClassFile trimClass(ClassFile cf, @Nullable String jarFile) {
     // drop non-constant fields
     ImmutableList.Builder<FieldInfo> fields = ImmutableList.builder();
     for (FieldInfo f : cf.fields()) {
@@ -80,6 +82,12 @@
         innerClasses.add(i);
       }
     }
+    // Include the original jar file name when repackaging transitive deps. If the same transitive
+    // dep is repackaged more than once, keep the original name.
+    String transitiveJar = cf.transitiveJar();
+    if (transitiveJar == null) {
+      transitiveJar = jarFile;
+    }
     return new ClassFile(
         cf.access(),
         cf.name(),
@@ -96,7 +104,8 @@
         cf.annotations(),
         innerClasses.build(),
         cf.typeAnnotations(),
-        /* module= */ null);
+        /* module= */ null,
+        /* transitiveJar = */ transitiveJar);
   }
 
   private static Set<ClassSymbol> superClosure(BindingResult bound) {
@@ -134,4 +143,6 @@
       addSuperTypes(closure, env, i);
     }
   }
+
+  private Transitive() {}
 }
diff --git a/java/com/google/turbine/diag/LineMap.java b/java/com/google/turbine/diag/LineMap.java
index 7a39aba..37d055b 100644
--- a/java/com/google/turbine/diag/LineMap.java
+++ b/java/com/google/turbine/diag/LineMap.java
@@ -17,6 +17,7 @@
 package com.google.turbine.diag;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.collect.ImmutableRangeMap;
 import com.google.common.collect.Range;
@@ -64,19 +65,22 @@
   /** The zero-indexed column number of the given source position. */
   public int column(int position) {
     checkArgument(0 <= position && position < source.length(), "%s", position);
-    return position - lines.getEntry(position).getKey().lowerEndpoint();
+    // requireNonNull is safe because `lines` covers the whole file length.
+    return position - requireNonNull(lines.getEntry(position)).getKey().lowerEndpoint();
   }
 
   /** The one-indexed line number of the given source position. */
   public int lineNumber(int position) {
     checkArgument(0 <= position && position < source.length(), "%s", position);
-    return lines.get(position);
+    // requireNonNull is safe because `lines` covers the whole file length.
+    return requireNonNull(lines.get(position));
   }
 
   /** The one-indexed line of the given source position. */
   public String line(int position) {
     checkArgument(0 <= position && position < source.length(), "%s", position);
-    Range<Integer> range = lines.getEntry(position).getKey();
+    // requireNonNull is safe because `lines` covers the whole file length.
+    Range<Integer> range = requireNonNull(lines.getEntry(position)).getKey();
     return source.substring(range.lowerEndpoint(), range.upperEndpoint());
   }
 }
diff --git a/java/com/google/turbine/diag/TurbineDiagnostic.java b/java/com/google/turbine/diag/TurbineDiagnostic.java
index ccbaa7f..ed04a5d 100644
--- a/java/com/google/turbine/diag/TurbineDiagnostic.java
+++ b/java/com/google/turbine/diag/TurbineDiagnostic.java
@@ -64,6 +64,10 @@
     return severity;
   }
 
+  boolean isError() {
+    return severity.equals(Diagnostic.Kind.ERROR);
+  }
+
   /** The diagnostic message. */
   public String diagnostic() {
     StringBuilder sb = new StringBuilder(path());
@@ -71,7 +75,7 @@
       sb.append(':').append(line());
     }
     sb.append(": error: ");
-    sb.append(message().trim()).append(System.lineSeparator());
+    sb.append(message()).append(System.lineSeparator());
     if (line() != -1 && column() != -1) {
       sb.append(CharMatcher.breakingWhitespace().trimTrailingFrom(source.lineMap().line(position)))
           .append(System.lineSeparator());
diff --git a/java/com/google/turbine/diag/TurbineError.java b/java/com/google/turbine/diag/TurbineError.java
index 39244b5..f3ebf08 100644
--- a/java/com/google/turbine/diag/TurbineError.java
+++ b/java/com/google/turbine/diag/TurbineError.java
@@ -26,15 +26,17 @@
 
   /** A diagnostic kind. */
   public enum ErrorKind {
-    UNEXPECTED_INPUT("unexpected input: %c"),
+    UNEXPECTED_INPUT("unexpected input: %s"),
     UNEXPECTED_IDENTIFIER("unexpected identifier '%s'"),
     UNEXPECTED_EOF("unexpected end of input"),
     UNTERMINATED_STRING("unterminated string literal"),
     UNTERMINATED_CHARACTER_LITERAL("unterminated char literal"),
+    UNPAIRED_SURROGATE("unpaired surrogate 0x%x"),
     UNTERMINATED_EXPRESSION("unterminated expression, expected ';' not found"),
     INVALID_UNICODE("illegal unicode escape"),
     EMPTY_CHARACTER_LITERAL("empty char literal"),
     EXPECTED_TOKEN("expected token %s"),
+    EXTENDS_AFTER_IMPLEMENTS("'extends' must come before 'implements'"),
     INVALID_LITERAL("invalid literal: %s"),
     UNEXPECTED_TYPE_PARAMETER("unexpected type parameter %s"),
     SYMBOL_NOT_FOUND("symbol not found %s"),
@@ -42,6 +44,7 @@
     TYPE_PARAMETER_QUALIFIER("type parameter used as type qualifier"),
     UNEXPECTED_TOKEN("unexpected token: %s"),
     INVALID_ANNOTATION_ARGUMENT("invalid annotation argument"),
+    MISSING_ANNOTATION_ARGUMENT("missing required annotation argument: %s"),
     CANNOT_RESOLVE("could not resolve %s"),
     EXPRESSION_ERROR("could not evaluate constant expression"),
     OPERAND_TYPE("bad operand type %s"),
@@ -51,6 +54,8 @@
     DUPLICATE_DECLARATION("duplicate declaration of %s"),
     BAD_MODULE_INFO("unexpected declaration found in module-info"),
     UNCLOSED_COMMENT("unclosed comment"),
+    UNEXPECTED_TYPE("unexpected type %s"),
+    UNEXPECTED_MODIFIER("unexpected modifier: %s"),
     PROC("%s");
 
     private final String message;
diff --git a/java/com/google/turbine/diag/TurbineLog.java b/java/com/google/turbine/diag/TurbineLog.java
index b336e25..565b9ea 100644
--- a/java/com/google/turbine/diag/TurbineLog.java
+++ b/java/com/google/turbine/diag/TurbineLog.java
@@ -18,7 +18,6 @@
 
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.diag.TurbineError.ErrorKind;
-import java.util.Iterator;
 import java.util.LinkedHashSet;
 import java.util.Set;
 import javax.tools.Diagnostic;
@@ -26,20 +25,24 @@
 /** A log that collects diagnostics. */
 public class TurbineLog {
 
-  private final Set<TurbineDiagnostic> errors = new LinkedHashSet<>();
+  private final Set<TurbineDiagnostic> diagnostics = new LinkedHashSet<>();
 
   public TurbineLogWithSource withSource(SourceFile source) {
     return new TurbineLogWithSource(source);
   }
 
+  public ImmutableList<TurbineDiagnostic> diagnostics() {
+    return ImmutableList.copyOf(diagnostics);
+  }
+
   public void maybeThrow() {
     if (anyErrors()) {
-      throw new TurbineError(ImmutableList.copyOf(errors));
+      throw new TurbineError(diagnostics());
     }
   }
 
-  private boolean anyErrors() {
-    for (TurbineDiagnostic error : errors) {
+  public boolean anyErrors() {
+    for (TurbineDiagnostic error : diagnostics) {
       if (error.severity().equals(Diagnostic.Kind.ERROR)) {
         return true;
       }
@@ -55,7 +58,7 @@
    * code generated in later processing rounds.
    */
   public boolean errorRaised() {
-    for (TurbineDiagnostic error : errors) {
+    for (TurbineDiagnostic error : diagnostics) {
       if (error.kind().equals(ErrorKind.PROC) && error.severity().equals(Diagnostic.Kind.ERROR)) {
         return true;
       }
@@ -65,17 +68,12 @@
 
   /** Reset the log between annotation processing rounds. */
   public void clear() {
-    Iterator<TurbineDiagnostic> it = errors.iterator();
-    while (it.hasNext()) {
-      if (it.next().severity().equals(Diagnostic.Kind.ERROR)) {
-        it.remove();
-      }
-    }
+    diagnostics.removeIf(TurbineDiagnostic::isError);
   }
 
   /** Reports an annotation processing diagnostic with no position information. */
   public void diagnostic(Diagnostic.Kind severity, String message) {
-    errors.add(TurbineDiagnostic.format(severity, ErrorKind.PROC, message));
+    diagnostics.add(TurbineDiagnostic.format(severity, ErrorKind.PROC, message));
   }
 
   /** A log for a specific source file. */
@@ -88,7 +86,7 @@
     }
 
     public void diagnostic(Diagnostic.Kind severity, int position, ErrorKind kind, Object... args) {
-      errors.add(TurbineDiagnostic.format(severity, source, position, kind, args));
+      diagnostics.add(TurbineDiagnostic.format(severity, source, position, kind, args));
     }
 
     public void error(int position, ErrorKind kind, Object... args) {
diff --git a/java/com/google/turbine/lower/Lower.java b/java/com/google/turbine/lower/Lower.java
index 0f7bb90..971bbe4 100644
--- a/java/com/google/turbine/lower/Lower.java
+++ b/java/com/google/turbine/lower/Lower.java
@@ -185,7 +185,8 @@
             annotations,
             innerClasses.build(),
             /* typeAnnotations= */ ImmutableList.of(),
-            moduleInfo);
+            moduleInfo,
+            /* transitiveJar= */ null);
     symbols.addAll(sig.classes);
     return ClassWriter.writeClass(classfile);
   }
@@ -279,7 +280,8 @@
             annotations,
             inners,
             typeAnnotations,
-            /* module= */ null);
+            /* module= */ null,
+            /* transitiveJar= */ null);
 
     symbols.addAll(sig.classes);
 
diff --git a/java/com/google/turbine/lower/LowerSignature.java b/java/com/google/turbine/lower/LowerSignature.java
index 13a7b9f..a08c7e8 100644
--- a/java/com/google/turbine/lower/LowerSignature.java
+++ b/java/com/google/turbine/lower/LowerSignature.java
@@ -128,15 +128,13 @@
    * unnecessary.
    */
   public String methodSignature(
-      Env<ClassSymbol, TypeBoundClass> env,
-      SourceTypeBoundClass.MethodInfo method,
-      ClassSymbol sym) {
+      Env<ClassSymbol, TypeBoundClass> env, TypeBoundClass.MethodInfo method, ClassSymbol sym) {
     if (!needsMethodSig(sym, env, method)) {
       return null;
     }
     ImmutableList<Sig.TyParamSig> typarams = tyParamSig(method.tyParams(), env);
     ImmutableList.Builder<Sig.TySig> fparams = ImmutableList.builder();
-    for (SourceTypeBoundClass.ParamInfo t : method.parameters()) {
+    for (TypeBoundClass.ParamInfo t : method.parameters()) {
       if (t.synthetic()) {
         continue;
       }
@@ -161,7 +159,7 @@
   }
 
   private boolean needsMethodSig(
-      ClassSymbol sym, Env<ClassSymbol, TypeBoundClass> env, SourceTypeBoundClass.MethodInfo m) {
+      ClassSymbol sym, Env<ClassSymbol, TypeBoundClass> env, TypeBoundClass.MethodInfo m) {
     if ((env.get(sym).access() & TurbineFlag.ACC_ENUM) == TurbineFlag.ACC_ENUM
         && m.name().equals("<init>")) {
       // JDK-8024694: javac always expects signature attribute for enum constructors
@@ -176,7 +174,7 @@
     if (m.returnType() != null && needsSig(m.returnType())) {
       return true;
     }
-    for (SourceTypeBoundClass.ParamInfo t : m.parameters()) {
+    for (TypeBoundClass.ParamInfo t : m.parameters()) {
       if (t.synthetic()) {
         continue;
       }
@@ -262,14 +260,14 @@
   private ImmutableList<Sig.TyParamSig> tyParamSig(
       Map<TyVarSymbol, TyVarInfo> px, Env<ClassSymbol, TypeBoundClass> env) {
     ImmutableList.Builder<Sig.TyParamSig> result = ImmutableList.builder();
-    for (Map.Entry<TyVarSymbol, SourceTypeBoundClass.TyVarInfo> entry : px.entrySet()) {
+    for (Map.Entry<TyVarSymbol, TyVarInfo> entry : px.entrySet()) {
       result.add(tyParamSig(entry.getKey(), entry.getValue(), env));
     }
     return result.build();
   }
 
   private Sig.TyParamSig tyParamSig(
-      TyVarSymbol sym, SourceTypeBoundClass.TyVarInfo info, Env<ClassSymbol, TypeBoundClass> env) {
+      TyVarSymbol sym, TyVarInfo info, Env<ClassSymbol, TypeBoundClass> env) {
 
     String identifier = sym.name();
     Sig.TySig cbound = null;
diff --git a/java/com/google/turbine/main/Main.java b/java/com/google/turbine/main/Main.java
index 1e60ae6..59563b6 100644
--- a/java/com/google/turbine/main/Main.java
+++ b/java/com/google/turbine/main/Main.java
@@ -70,7 +70,7 @@
 import java.util.zip.ZipEntry;
 
 /** Main entry point for the turbine CLI. */
-public class Main {
+public final class Main {
 
   private static final int BUFFER_SIZE = 65536;
 
@@ -256,9 +256,10 @@
         ClassPathBinder.bindClasspath(toPaths(classpath)),
         Processing.initializeProcessors(
             /* javacopts= */ options.javacOpts(),
-            /* processorPath= */ options.processorPath(),
             /* processorNames= */ options.processors(),
-            /* builtinProcessors= */ options.builtinProcessors()),
+            Processing.processorLoader(
+                /* processorPath= */ options.processorPath(),
+                /* builtinProcessors= */ options.builtinProcessors())),
         bootclasspath,
         /* moduleVersion=*/ Optional.empty());
   }
@@ -332,6 +333,14 @@
       return;
     }
     Path path = Paths.get(options.gensrcOutput().get());
+    if (Files.isDirectory(path)) {
+      for (SourceFile source : generatedSources.values()) {
+        Path to = path.resolve(source.path());
+        Files.createDirectories(to.getParent());
+        Files.write(to, source.source().getBytes(UTF_8));
+      }
+      return;
+    }
     try (OutputStream os = Files.newOutputStream(path);
         BufferedOutputStream bos = new BufferedOutputStream(os, BUFFER_SIZE);
         JarOutputStream jos = new JarOutputStream(bos)) {
@@ -349,6 +358,14 @@
       return;
     }
     Path path = Paths.get(options.resourceOutput().get());
+    if (Files.isDirectory(path)) {
+      for (Map.Entry<String, byte[]> resource : generatedResources.entrySet()) {
+        Path to = path.resolve(resource.getKey());
+        Files.createDirectories(to.getParent());
+        Files.write(to, resource.getValue());
+      }
+      return;
+    }
     try (OutputStream os = Files.newOutputStream(path);
         BufferedOutputStream bos = new BufferedOutputStream(os, BUFFER_SIZE);
         JarOutputStream jos = new JarOutputStream(bos)) {
@@ -465,4 +482,6 @@
     }
     return result.build();
   }
+
+  private Main() {}
 }
diff --git a/java/com/google/turbine/model/TurbineFlag.java b/java/com/google/turbine/model/TurbineFlag.java
index 48e88e7..c138d46 100644
--- a/java/com/google/turbine/model/TurbineFlag.java
+++ b/java/com/google/turbine/model/TurbineFlag.java
@@ -22,7 +22,7 @@
  * <p>See tables 4.1-A, 4.5-A, 4.6-A, and 4.7.6-A in JVMS 4:
  * https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html
  */
-public class TurbineFlag {
+public final class TurbineFlag {
   public static final int ACC_PUBLIC = 0x0001;
   public static final int ACC_PRIVATE = 0x0002;
   public static final int ACC_PROTECTED = 0x0004;
@@ -54,4 +54,6 @@
 
   /** Synthetic constructors (e.g. of inner classes and enums). */
   public static final int ACC_SYNTH_CTOR = 1 << 18;
+
+  private TurbineFlag() {}
 }
diff --git a/java/com/google/turbine/options/TurbineOptions.java b/java/com/google/turbine/options/TurbineOptions.java
index 4dcc408..c104c54 100644
--- a/java/com/google/turbine/options/TurbineOptions.java
+++ b/java/com/google/turbine/options/TurbineOptions.java
@@ -70,17 +70,6 @@
   /** The output jar. */
   public abstract Optional<String> output();
 
-  /**
-   * The output jar.
-   *
-   * @deprecated use {@link #output} instead.
-   */
-  @Deprecated
-  @Nullable
-  public String outputFile() {
-    return output().orElse(null);
-  }
-
   /** Paths to annotation processor artifacts. */
   public abstract ImmutableList<String> processorPath();
 
@@ -160,56 +149,20 @@
   public abstract static class Builder {
     public abstract Builder setOutput(String output);
 
-    /** @deprecated use {@link #setClassPath(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addClassPathEntries(Iterable<String> sources) {
-      return setClassPath(ImmutableList.copyOf(sources));
-    }
-
     public abstract Builder setClassPath(ImmutableList<String> classPath);
 
     public abstract Builder setBootClassPath(ImmutableList<String> bootClassPath);
 
-    /** @deprecated use {@link #setBootClassPath(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addBootClassPathEntries(Iterable<String> sources) {
-      return setBootClassPath(ImmutableList.copyOf(sources));
-    }
-
     public abstract Builder setRelease(String release);
 
     public abstract Builder setSystem(String system);
 
     public abstract Builder setSources(ImmutableList<String> sources);
 
-    /** @deprecated use {@link #setSources(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addSources(Iterable<String> sources) {
-      return setSources(ImmutableList.copyOf(sources));
-    }
-
-    /** @deprecated use {@link #setProcessorPath(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addProcessorPathEntries(Iterable<String> processorPath) {
-      return setProcessorPath(ImmutableList.copyOf(processorPath));
-    }
-
     public abstract Builder setProcessorPath(ImmutableList<String> processorPath);
 
-    /** @deprecated use {@link #setProcessors(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addProcessors(Iterable<String> processors) {
-      return setProcessors(ImmutableList.copyOf(processors));
-    }
-
     public abstract Builder setProcessors(ImmutableList<String> processors);
 
-    /** @deprecated use {@link #setBuiltinProcessors(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addBuiltinProcessors(Iterable<String> builtinProcessors) {
-      return setBuiltinProcessors(ImmutableList.copyOf(builtinProcessors));
-    }
-
     public abstract Builder setBuiltinProcessors(ImmutableList<String> builtinProcessors);
 
     public abstract Builder setSourceJars(ImmutableList<String> sourceJars);
@@ -222,12 +175,6 @@
 
     public abstract Builder setInjectingRuleKind(String injectingRuleKind);
 
-    /** @deprecated use {@link #setDepsArtifacts(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addAllDepsArtifacts(Iterable<String> depsArtifacts) {
-      return setDepsArtifacts(ImmutableList.copyOf(depsArtifacts));
-    }
-
     public abstract Builder setDepsArtifacts(ImmutableList<String> depsArtifacts);
 
     public abstract Builder setHelp(boolean help);
@@ -241,12 +188,6 @@
 
     public abstract Builder setReducedClasspathMode(ReducedClasspathMode reducedClasspathMode);
 
-    /** @deprecated use {@link #setDirectJars(ImmutableList)} instead. */
-    @Deprecated
-    public Builder addDirectJars(Iterable<String> directJars) {
-      return setDirectJars(ImmutableList.copyOf(directJars));
-    }
-
     public abstract Builder setDirectJars(ImmutableList<String> jars);
 
     public abstract Builder setProfile(String profile);
@@ -261,4 +202,11 @@
 
     public abstract TurbineOptions build();
   }
+
+  // TODO(b/188833569): remove when AutoValue adds @Nullable to Object if its on the classpath
+  @Override
+  public abstract boolean equals(@Nullable Object other);
+
+  @Override
+  public abstract int hashCode();
 }
diff --git a/java/com/google/turbine/options/TurbineOptionsParser.java b/java/com/google/turbine/options/TurbineOptionsParser.java
index 17d4bf6..4a8ff16 100644
--- a/java/com/google/turbine/options/TurbineOptionsParser.java
+++ b/java/com/google/turbine/options/TurbineOptionsParser.java
@@ -30,10 +30,9 @@
 import java.util.ArrayDeque;
 import java.util.Deque;
 import java.util.Iterator;
-import org.checkerframework.checker.nullness.qual.Nullable;
 
 /** A command line options parser for {@link TurbineOptions}. */
-public class TurbineOptionsParser {
+public final class TurbineOptionsParser {
 
   /**
    * Parses command line options into {@link TurbineOptions}, expanding any {@code @params} files.
@@ -57,17 +56,17 @@
 
   private static void parse(TurbineOptions.Builder builder, Deque<String> argumentDeque) {
     while (!argumentDeque.isEmpty()) {
-      String next = argumentDeque.pollFirst();
+      String next = argumentDeque.removeFirst();
       switch (next) {
         case "--output":
-          builder.setOutput(readOne(argumentDeque));
+          builder.setOutput(readOne(next, argumentDeque));
           break;
         case "--source_jars":
           builder.setSourceJars(readList(argumentDeque));
           break;
         case "--temp_dir":
           // TODO(cushon): remove this when Bazel no longer passes the flag
-          readOne(argumentDeque);
+          readOne(next, argumentDeque);
           break;
         case "--processors":
           builder.setProcessors(readList(argumentDeque));
@@ -85,10 +84,10 @@
           builder.setBootClassPath(readList(argumentDeque));
           break;
         case "--release":
-          builder.setRelease(readOne(argumentDeque));
+          builder.setRelease(readOne(next, argumentDeque));
           break;
         case "--system":
-          builder.setSystem(readOne(argumentDeque));
+          builder.setSystem(readOne(next, argumentDeque));
           break;
         case "--javacopts":
           {
@@ -100,11 +99,12 @@
         case "--sources":
           builder.setSources(readList(argumentDeque));
           break;
+        case "--output_deps_proto":
         case "--output_deps":
-          builder.setOutputDeps(readOne(argumentDeque));
+          builder.setOutputDeps(readOne(next, argumentDeque));
           break;
         case "--output_manifest_proto":
-          builder.setOutputManifest(readOne(argumentDeque));
+          builder.setOutputManifest(readOne(next, argumentDeque));
           break;
         case "--direct_dependencies":
           builder.setDirectJars(readList(argumentDeque));
@@ -113,10 +113,10 @@
           builder.setDepsArtifacts(readList(argumentDeque));
           break;
         case "--target_label":
-          builder.setTargetLabel(readOne(argumentDeque));
+          builder.setTargetLabel(readOne(next, argumentDeque));
           break;
         case "--injecting_rule_kind":
-          builder.setInjectingRuleKind(readOne(argumentDeque));
+          builder.setInjectingRuleKind(readOne(next, argumentDeque));
           break;
         case "--javac_fallback":
         case "--nojavac_fallback":
@@ -129,26 +129,37 @@
           builder.setReducedClasspathMode(ReducedClasspathMode.NONE);
           break;
         case "--reduce_classpath_mode":
-          builder.setReducedClasspathMode(ReducedClasspathMode.valueOf(readOne(argumentDeque)));
+          builder.setReducedClasspathMode(
+              ReducedClasspathMode.valueOf(readOne(next, argumentDeque)));
           break;
         case "--full_classpath_length":
-          builder.setFullClasspathLength(Integer.parseInt(readOne(argumentDeque)));
+          builder.setFullClasspathLength(Integer.parseInt(readOne(next, argumentDeque)));
           break;
         case "--reduced_classpath_length":
-          builder.setReducedClasspathLength(Integer.parseInt(readOne(argumentDeque)));
+          builder.setReducedClasspathLength(Integer.parseInt(readOne(next, argumentDeque)));
           break;
         case "--profile":
-          builder.setProfile(readOne(argumentDeque));
+          builder.setProfile(readOne(next, argumentDeque));
           break;
+        case "--generated_sources_output":
         case "--gensrc_output":
-          builder.setGensrcOutput(readOne(argumentDeque));
+          builder.setGensrcOutput(readOne(next, argumentDeque));
           break;
         case "--resource_output":
-          builder.setResourceOutput(readOne(argumentDeque));
+          builder.setResourceOutput(readOne(next, argumentDeque));
           break;
         case "--help":
           builder.setHelp(true);
           break;
+        case "--experimental_fix_deps_tool":
+        case "--strict_java_deps":
+        case "--native_header_output":
+          // accepted (and ignored) for compatibility with JavaBuilder command lines
+          readOne(next, argumentDeque);
+          break;
+        case "--compress_jar":
+          // accepted (and ignored) for compatibility with JavaBuilder command lines
+          break;
         default:
           throw new IllegalArgumentException("unknown option: " + next);
       }
@@ -190,20 +201,22 @@
     }
   }
 
-  /** Returns the value of an option, or {@code null}. */
-  @Nullable
-  private static String readOne(Deque<String> argumentDeque) {
-    if (argumentDeque.isEmpty() || argumentDeque.peekFirst().startsWith("-")) {
-      return null;
+  /**
+   * Returns the value of an option, or throws {@link IllegalArgumentException} if the value is not
+   * present.
+   */
+  private static String readOne(String flag, Deque<String> argumentDeque) {
+    if (argumentDeque.isEmpty() || argumentDeque.getFirst().startsWith("-")) {
+      throw new IllegalArgumentException("missing required argument for: " + flag);
     }
-    return argumentDeque.pollFirst();
+    return argumentDeque.removeFirst();
   }
 
   /** Returns a list of option values. */
   private static ImmutableList<String> readList(Deque<String> argumentDeque) {
     ImmutableList.Builder<String> result = ImmutableList.builder();
-    while (!argumentDeque.isEmpty() && !argumentDeque.peekFirst().startsWith("--")) {
-      result.add(argumentDeque.pollFirst());
+    while (!argumentDeque.isEmpty() && !argumentDeque.getFirst().startsWith("--")) {
+      result.add(argumentDeque.removeFirst());
     }
     return result.build();
   }
@@ -215,7 +228,7 @@
   private static ImmutableList<String> readJavacopts(Deque<String> argumentDeque) {
     ImmutableList.Builder<String> result = ImmutableList.builder();
     while (!argumentDeque.isEmpty()) {
-      String arg = argumentDeque.pollFirst();
+      String arg = argumentDeque.removeFirst();
       if (arg.equals("--")) {
         return result.build();
       }
@@ -237,4 +250,6 @@
       }
     }
   }
+
+  private TurbineOptionsParser() {}
 }
diff --git a/java/com/google/turbine/options/package-info.java b/java/com/google/turbine/options/package-info.java
new file mode 100644
index 0000000..9c12bf8
--- /dev/null
+++ b/java/com/google/turbine/options/package-info.java
@@ -0,0 +1,17 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.options;
diff --git a/java/com/google/turbine/parse/ConstExpressionParser.java b/java/com/google/turbine/parse/ConstExpressionParser.java
index e49d51c..ba51814 100644
--- a/java/com/google/turbine/parse/ConstExpressionParser.java
+++ b/java/com/google/turbine/parse/ConstExpressionParser.java
@@ -506,12 +506,15 @@
         return term1;
       }
       eat();
-      if (op == TurbineOperatorKind.TERNARY) {
-        term1 = ternary(term1);
-      } else if (op == TurbineOperatorKind.ASSIGN) {
-        term1 = assign(term1, op);
-      } else {
-        term1 = new Tree.Binary(position, term1, expression(op.prec()), op);
+      switch (op) {
+        case TERNARY:
+          term1 = ternary(term1);
+          break;
+        case ASSIGN:
+          term1 = assign(term1, op);
+          break;
+        default:
+          term1 = new Tree.Binary(position, term1, expression(op.prec()), op);
       }
       if (term1 == null) {
         return null;
@@ -568,6 +571,7 @@
       throw new AssertionError();
     }
     eat();
+    int pos = position;
     Tree.ConstVarName constVarName = (Tree.ConstVarName) qualIdent();
     if (constVarName == null) {
       return null;
@@ -577,10 +581,10 @@
     if (token == Token.LPAREN) {
       eat();
       while (token != Token.RPAREN) {
-        int pos = position;
+        int argPos = position;
         Tree.Expression expression = expression();
         if (expression == null) {
-          throw TurbineError.format(lexer.source(), pos, ErrorKind.INVALID_ANNOTATION_ARGUMENT);
+          throw TurbineError.format(lexer.source(), argPos, ErrorKind.INVALID_ANNOTATION_ARGUMENT);
         }
         args.add(expression);
         if (token != Token.COMMA) {
@@ -592,11 +596,19 @@
         eat();
       }
     }
-    return new Tree.AnnoExpr(position, new Tree.Anno(position, name, args.build()));
+    return new Tree.AnnoExpr(pos, new Tree.Anno(pos, name, args.build()));
   }
 
   @CheckReturnValue
   private TurbineError error(ErrorKind kind, Object... args) {
     return TurbineError.format(lexer.source(), lexer.position(), kind, args);
   }
+
+  public int f() {
+    return helper(1, 2);
+  }
+
+  private int helper(int x, int y) {
+    return x + y;
+  }
 }
diff --git a/java/com/google/turbine/parse/Parser.java b/java/com/google/turbine/parse/Parser.java
index 4a090b3..af1eabf 100644
--- a/java/com/google/turbine/parse/Parser.java
+++ b/java/com/google/turbine/parse/Parser.java
@@ -519,7 +519,15 @@
         interfaces.add(classty());
       } while (maybe(Token.COMMA));
     }
-    eat(Token.LBRACE);
+    switch (token) {
+      case LBRACE:
+        next();
+        break;
+      case EXTENDS:
+        throw error(ErrorKind.EXTENDS_AFTER_IMPLEMENTS);
+      default:
+        throw error(ErrorKind.EXPECTED_TOKEN, Token.LBRACE);
+    }
     ImmutableList<Tree> members = classMembers();
     eat(Token.RBRACE);
     return new TyDecl(
@@ -748,7 +756,9 @@
           }
           if (token == Token.DOT) {
             next();
-            // TODO(cushon): is this cast OK?
+            if (!result.kind().equals(Kind.CLASS_TY)) {
+              throw error(token);
+            }
             result = classty((ClassTy) result);
           }
           result = maybeDims(maybeAnnos(), result);
diff --git a/java/com/google/turbine/parse/StreamLexer.java b/java/com/google/turbine/parse/StreamLexer.java
index 2e20c26..991b5fd 100644
--- a/java/com/google/turbine/parse/StreamLexer.java
+++ b/java/com/google/turbine/parse/StreamLexer.java
@@ -29,7 +29,7 @@
   private final UnicodeEscapePreprocessor reader;
 
   /** The current input character. */
-  private char ch;
+  private int ch;
 
   /** The start position of the current token. */
   private int position;
@@ -353,7 +353,7 @@
                     eat();
                     return Token.ELLIPSIS;
                   } else {
-                    throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+                    throw inputError();
                   }
                 }
               case '0':
@@ -384,7 +384,7 @@
               case '\'':
                 throw error(ErrorKind.EMPTY_CHARACTER_LITERAL);
               default:
-                value = ch;
+                value = (char) ch;
                 eat();
             }
             if (ch == '\'') {
@@ -419,7 +419,7 @@
                   }
                   // falls through
                 default:
-                  sb.append(ch);
+                  sb.appendCodePoint(ch);
                   eat();
                   continue STRING;
               }
@@ -430,7 +430,7 @@
             // TODO(cushon): the style guide disallows non-ascii identifiers
             return identifier();
           }
-          throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+          throw inputError();
       }
     }
   }
@@ -511,7 +511,7 @@
           }
         }
       default:
-        throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+        throw inputError();
     }
   }
 
@@ -623,7 +623,7 @@
         eat();
         break;
       default:
-        throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+        throw inputError();
     }
     OUTER:
     while (true) {
@@ -658,7 +658,7 @@
               case '9':
                 continue OUTER;
               default:
-                throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+                throw inputError();
             }
           }
         case 'A':
@@ -695,7 +695,7 @@
     if ('0' <= ch && ch <= '9') {
       eat();
     } else {
-      throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+      throw inputError();
     }
     OUTER:
     while (true) {
@@ -707,7 +707,7 @@
           if ('0' <= ch && ch <= '9') {
             continue OUTER;
           } else {
-            throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+            throw inputError();
           }
         case '0':
         case '1':
@@ -746,7 +746,7 @@
         eat();
         break;
       default:
-        throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+        throw inputError();
     }
     OUTER:
     while (true) {
@@ -760,7 +760,7 @@
             case '1':
               continue OUTER;
             default:
-              throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+              throw inputError();
           }
         case '0':
         case '1':
@@ -798,7 +798,7 @@
         eat();
         break;
       default:
-        throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+        throw inputError();
     }
     OUTER:
     while (true) {
@@ -818,7 +818,7 @@
             case '7':
               continue OUTER;
             default:
-              throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+              throw inputError();
           }
         case '0':
         case '1':
@@ -992,7 +992,7 @@
         }
       case '/':
         // handled with comments
-        throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+        throw inputError();
 
       case '%':
         eat();
@@ -1011,7 +1011,7 @@
           return Token.XOR;
         }
       default:
-        throw error(ErrorKind.UNEXPECTED_INPUT, ch);
+        throw inputError();
     }
   }
 
@@ -1141,6 +1141,12 @@
     }
   }
 
+  private TurbineError inputError() {
+    return error(
+        ErrorKind.UNEXPECTED_INPUT,
+        Character.isBmpCodePoint(ch) ? Character.toString((char) ch) : String.format("U+%X", ch));
+  }
+
   private TurbineError error(ErrorKind kind, Object... args) {
     return TurbineError.format(reader.source(), reader.position(), kind, args);
   }
diff --git a/java/com/google/turbine/parse/UnicodeEscapePreprocessor.java b/java/com/google/turbine/parse/UnicodeEscapePreprocessor.java
index 3f38561..4146ca5 100644
--- a/java/com/google/turbine/parse/UnicodeEscapePreprocessor.java
+++ b/java/com/google/turbine/parse/UnicodeEscapePreprocessor.java
@@ -30,7 +30,7 @@
   private final String input;
 
   private int idx = 0;
-  private char ch;
+  private int ch;
   private boolean evenLeadingSlashes = true;
 
   public UnicodeEscapePreprocessor(SourceFile source) {
@@ -49,7 +49,7 @@
   }
 
   /** Returns the next unescaped Unicode input character. */
-  public char next() {
+  public int next() {
     eat();
     if (ch == '\\' && evenLeadingSlashes) {
       unicodeEscape();
@@ -88,7 +88,7 @@
   }
 
   /** Consumes a hex digit. */
-  private int hexDigit(char d) {
+  private int hexDigit(int d) {
     switch (d) {
       case '0':
       case '1':
@@ -130,8 +130,20 @@
    * it terminates the input avoids some bounds checks in the lexer.
    */
   private void eat() {
-    ch = done() ? ASCII_SUB : input.charAt(idx);
+    char hi = done() ? ASCII_SUB : input.charAt(idx);
     idx++;
+    if (!Character.isHighSurrogate(hi)) {
+      ch = hi;
+      return;
+    }
+    if (done()) {
+      throw error(ErrorKind.UNPAIRED_SURROGATE, (int) hi);
+    }
+    char lo = input.charAt(idx++);
+    if (!Character.isLowSurrogate(lo)) {
+      throw error(ErrorKind.UNPAIRED_SURROGATE, (int) hi);
+    }
+    ch = Character.toCodePoint(hi, lo);
   }
 
   public SourceFile source() {
diff --git a/java/com/google/turbine/parse/VariableInitializerParser.java b/java/com/google/turbine/parse/VariableInitializerParser.java
index 4ad9272..7f4d40e 100644
--- a/java/com/google/turbine/parse/VariableInitializerParser.java
+++ b/java/com/google/turbine/parse/VariableInitializerParser.java
@@ -40,10 +40,10 @@
  * <p>That handles everything except multi-variable declarations (int x = 1, y = 2;), which in
  * hindsight were probably a mistake. Multi-variable declarations contain a list of name and
  * initializer pairs separated by commas. The initializer expressions may also contain commas, so
- * it's non-trivial to split on initializer boundaries. For example, consider `int x = a < b, c =
- * d;`. We can't tell looking at the prefix `a < b, c` whether that's a less-than expression
- * followed by another initializer, or the start of a generic type: `a<b, c>.foo()`. Distinguishing
- * between these cases requires arbitrary lookahead.
+ * it's non-trivial to split on initializer boundaries. For example, consider {@code int x = a < b,
+ * c = d;}. We can't tell looking at the prefix {@code a < b, c} whether that's a less-than
+ * expression followed by another initializer, or the start of a generic type: {@code a<b, c>.foo(}.
+ * Distinguishing between these cases requires arbitrary lookahead.
  *
  * <p>The preprocessor seems to be operationally correct. It's possible there are edge cases that it
  * doesn't handle, but it's extremely rare for compile-time constant multi-variable declarations to
@@ -330,6 +330,8 @@
           depth--;
           next();
           break;
+        case EOF:
+          throw error(ErrorKind.UNEXPECTED_EOF);
         default:
           next();
           break;
diff --git a/java/com/google/turbine/processing/TurbineAnnotationMirror.java b/java/com/google/turbine/processing/TurbineAnnotationMirror.java
index 5ea3de1..df3bd19 100644
--- a/java/com/google/turbine/processing/TurbineAnnotationMirror.java
+++ b/java/com/google/turbine/processing/TurbineAnnotationMirror.java
@@ -18,6 +18,7 @@
 
 import static com.google.common.base.Preconditions.checkState;
 import static com.google.common.collect.Iterables.getLast;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Supplier;
@@ -115,8 +116,13 @@
                 ImmutableMap.Builder<ExecutableElement, AnnotationValue> result =
                     ImmutableMap.builder();
                 for (Map.Entry<String, Const> value : anno.values().entrySet()) {
+                  // requireNonNull is safe because `elements` contains an entry for every method.
+                  // Any element values pairs without a corresponding method in the annotation
+                  // definition are weeded out in ConstEvaluator.evaluateAnnotation, and don't
+                  // appear in the AnnoInfo.
+                  MethodInfo methodInfo = requireNonNull(elements.get().get(value.getKey()));
                   result.put(
-                      factory.executableElement(elements.get().get(value.getKey()).sym()),
+                      factory.executableElement(methodInfo.sym()),
                       annotationValue(factory, value.getValue()));
                 }
                 return result.build();
diff --git a/java/com/google/turbine/processing/TurbineAnnotationProxy.java b/java/com/google/turbine/processing/TurbineAnnotationProxy.java
index c39f310..967ead9 100644
--- a/java/com/google/turbine/processing/TurbineAnnotationProxy.java
+++ b/java/com/google/turbine/processing/TurbineAnnotationProxy.java
@@ -17,6 +17,7 @@
 package com.google.turbine.processing;
 
 import static com.google.common.base.Preconditions.checkArgument;
+import static java.util.Objects.requireNonNull;
 
 import com.google.turbine.binder.bound.EnumConstantValue;
 import com.google.turbine.binder.bound.TurbineAnnotationValue;
@@ -131,14 +132,15 @@
 
   private static Object constArrayValue(
       Class<?> returnType, ModelFactory factory, ClassLoader loader, ArrayInitValue value) {
-    if (returnType.getComponentType().equals(Class.class)) {
+    Class<?> componentType = requireNonNull(returnType.getComponentType());
+    if (componentType.equals(Class.class)) {
       List<TypeMirror> result = new ArrayList<>();
       for (Const element : value.elements()) {
         result.add(factory.asTypeMirror(((TurbineClassValue) element).type()));
       }
       throw new MirroredTypesException(result);
     }
-    Object result = Array.newInstance(returnType.getComponentType(), value.elements().size());
+    Object result = Array.newInstance(componentType, value.elements().size());
     int idx = 0;
     for (Const element : value.elements()) {
       Object v = constValue(returnType, factory, loader, element);
diff --git a/java/com/google/turbine/processing/TurbineElement.java b/java/com/google/turbine/processing/TurbineElement.java
index c22a442..f4f1675 100644
--- a/java/com/google/turbine/processing/TurbineElement.java
+++ b/java/com/google/turbine/processing/TurbineElement.java
@@ -46,7 +46,7 @@
 import com.google.turbine.model.Const;
 import com.google.turbine.model.Const.ArrayInitValue;
 import com.google.turbine.model.TurbineFlag;
-import com.google.turbine.model.TurbineTyKind;
+import com.google.turbine.tree.Tree;
 import com.google.turbine.tree.Tree.MethDecl;
 import com.google.turbine.tree.Tree.TyDecl;
 import com.google.turbine.tree.Tree.VarDecl;
@@ -158,7 +158,8 @@
         continue;
       }
       if (anno.sym().equals(metadata.repeatable())) {
-        ArrayInitValue arrayValue = (ArrayInitValue) anno.values().get("value");
+        // requireNonNull is safe because java.lang.annotation.Repeatable declares `value`.
+        ArrayInitValue arrayValue = (ArrayInitValue) requireNonNull(anno.values().get("value"));
         for (Const element : arrayValue.elements()) {
           result.add(
               TurbineAnnotationProxy.create(
@@ -262,11 +263,16 @@
                       return factory.asTypeMirror(info.superClassType());
                     }
                     if (info instanceof SourceTypeBoundClass) {
-                      // support simple name for stuff that doesn't exist
+                      // support simple names for stuff that doesn't exist
                       TyDecl decl = ((SourceTypeBoundClass) info).decl();
                       if (decl.xtnds().isPresent()) {
-                        return factory.asTypeMirror(
-                            ErrorTy.create(decl.xtnds().get().name().value()));
+                        ArrayDeque<Tree.Ident> flat = new ArrayDeque<>();
+                        for (Tree.ClassTy curr = decl.xtnds().get();
+                            curr != null;
+                            curr = curr.base().orElse(null)) {
+                          flat.addFirst(curr.name());
+                        }
+                        return factory.asTypeMirror(ErrorTy.create(flat));
                       }
                     }
                     return factory.noType();
@@ -785,18 +791,12 @@
 
     @Override
     public ElementKind getKind() {
-      return info().name().equals("<init>") ? ElementKind.CONSTRUCTOR : ElementKind.METHOD;
+      return sym.name().equals("<init>") ? ElementKind.CONSTRUCTOR : ElementKind.METHOD;
     }
 
     @Override
     public Set<Modifier> getModifiers() {
-      int access = info().access();
-      if (factory.getSymbol(info().sym().owner()).kind() == TurbineTyKind.INTERFACE) {
-        if ((access & (TurbineFlag.ACC_ABSTRACT | TurbineFlag.ACC_STATIC)) == 0) {
-          access |= TurbineFlag.ACC_DEFAULT;
-        }
-      }
-      return asModifierSet(ModifierOwner.METHOD, access);
+      return asModifierSet(ModifierOwner.METHOD, info().access());
     }
 
     @Override
diff --git a/java/com/google/turbine/processing/TurbineElements.java b/java/com/google/turbine/processing/TurbineElements.java
index 9da210e..7ede6e3 100644
--- a/java/com/google/turbine/processing/TurbineElements.java
+++ b/java/com/google/turbine/processing/TurbineElements.java
@@ -131,7 +131,7 @@
     if (!(element instanceof TurbineElement)) {
       throw new IllegalArgumentException(element.toString());
     }
-    for (AnnoInfo a : ((TurbineTypeElement) element).annos()) {
+    for (AnnoInfo a : ((TurbineElement) element).annos()) {
       if (a.sym().equals(ClassSymbol.DEPRECATED)) {
         return true;
       }
@@ -265,8 +265,8 @@
   }
 
   /**
-   * Returns true if an element with the given {@code visibility} and located in package {@from} is
-   * visible to elements in package {@code to}.
+   * Returns true if an element with the given {@code visibility} and located in package {@code
+   * from} is visible to elements in package {@code to}.
    */
   private static boolean isVisible(
       PackageSymbol from, PackageSymbol to, TurbineVisibility visibility) {
diff --git a/java/com/google/turbine/processing/TurbineFiler.java b/java/com/google/turbine/processing/TurbineFiler.java
index 186eb7f..45cdc22 100644
--- a/java/com/google/turbine/processing/TurbineFiler.java
+++ b/java/com/google/turbine/processing/TurbineFiler.java
@@ -18,6 +18,7 @@
 
 import static com.google.common.base.Preconditions.checkArgument;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Function;
 import com.google.common.base.Supplier;
@@ -361,7 +362,7 @@
     @Override
     public URI toUri() {
       try {
-        return loader.getResource(path).toURI();
+        return requireNonNull(loader.getResource(path)).toURI();
       } catch (URISyntaxException e) {
         throw new AssertionError(e);
       }
diff --git a/java/com/google/turbine/processing/TurbineProcessingEnvironment.java b/java/com/google/turbine/processing/TurbineProcessingEnvironment.java
index 726d075..8b44e75 100644
--- a/java/com/google/turbine/processing/TurbineProcessingEnvironment.java
+++ b/java/com/google/turbine/processing/TurbineProcessingEnvironment.java
@@ -26,7 +26,7 @@
 import javax.lang.model.util.Types;
 import org.checkerframework.checker.nullness.qual.Nullable;
 
-/** Turbine's {@link ProcessingEnvironment). */
+/** Turbine's {@link ProcessingEnvironment}. */
 public class TurbineProcessingEnvironment implements ProcessingEnvironment {
 
   private final Filer filer;
diff --git a/java/com/google/turbine/processing/TurbineTypes.java b/java/com/google/turbine/processing/TurbineTypes.java
index f65f921..7d2e6c0 100644
--- a/java/com/google/turbine/processing/TurbineTypes.java
+++ b/java/com/google/turbine/processing/TurbineTypes.java
@@ -825,7 +825,7 @@
 
   @Override
   public List<? extends TypeMirror> directSupertypes(TypeMirror m) {
-    return factory.asTypeMirrors(directSupertypes(asTurbineType(m)));
+    return factory.asTypeMirrors(deannotate(directSupertypes(asTurbineType(m))));
   }
 
   public ImmutableList<Type> directSupertypes(Type t) {
@@ -882,7 +882,12 @@
 
   @Override
   public TypeMirror erasure(TypeMirror typeMirror) {
-    return factory.asTypeMirror(erasure(asTurbineType(typeMirror)));
+    Type t = erasure(asTurbineType(typeMirror));
+    if (t.tyKind() == TyKind.CLASS_TY) {
+      // bug-parity with javac
+      t = deannotate(t);
+    }
+    return factory.asTypeMirror(t);
   }
 
   private Type erasure(Type type) {
@@ -896,6 +901,50 @@
         });
   }
 
+  /**
+   * Remove some type annotation metadata for bug-compatibility with javac, which does this
+   * inconsistently (see https://bugs.openjdk.java.net/browse/JDK-8042981).
+   */
+  private static Type deannotate(Type ty) {
+    switch (ty.tyKind()) {
+      case CLASS_TY:
+        return deannotateClassTy((Type.ClassTy) ty);
+      case ARRAY_TY:
+        return deannotateArrayTy((Type.ArrayTy) ty);
+      case TY_VAR:
+      case INTERSECTION_TY:
+      case WILD_TY:
+      case METHOD_TY:
+      case PRIM_TY:
+      case VOID_TY:
+      case ERROR_TY:
+      case NONE_TY:
+        return ty;
+    }
+    throw new AssertionError(ty.tyKind());
+  }
+
+  private static ImmutableList<Type> deannotate(ImmutableList<Type> types) {
+    ImmutableList.Builder<Type> result = ImmutableList.builder();
+    for (Type type : types) {
+      result.add(deannotate(type));
+    }
+    return result.build();
+  }
+
+  private static Type.ArrayTy deannotateArrayTy(Type.ArrayTy ty) {
+    return ArrayTy.create(deannotate(ty.elementType()), /* annos= */ ImmutableList.of());
+  }
+
+  public static Type.ClassTy deannotateClassTy(Type.ClassTy ty) {
+    ImmutableList.Builder<Type.ClassTy.SimpleClassTy> classes = ImmutableList.builder();
+    for (Type.ClassTy.SimpleClassTy c : ty.classes()) {
+      classes.add(
+          SimpleClassTy.create(c.sym(), deannotate(c.targs()), /* annos= */ ImmutableList.of()));
+    }
+    return ClassTy.create(classes.build());
+  }
+
   @Override
   public TypeElement boxedClass(PrimitiveType p) {
     return factory.typeElement(boxedClass(((PrimTy) asTurbineType(p)).primkind()));
diff --git a/java/com/google/turbine/type/Type.java b/java/com/google/turbine/type/Type.java
index daba2ae..bdddc6c 100644
--- a/java/com/google/turbine/type/Type.java
+++ b/java/com/google/turbine/type/Type.java
@@ -246,11 +246,14 @@
     @Override
     public final String toString() {
       StringBuilder sb = new StringBuilder();
-      for (AnnoInfo anno : annos()) {
-        sb.append(anno);
-        sb.append(' ');
-      }
       sb.append(elementType());
+      if (!annos().isEmpty()) {
+        sb.append(' ');
+        for (AnnoInfo anno : annos()) {
+          sb.append(anno);
+          sb.append(' ');
+        }
+      }
       sb.append("[]");
       return sb.toString();
     }
diff --git a/java/com/google/turbine/types/Deannotate.java b/java/com/google/turbine/types/Deannotate.java
new file mode 100644
index 0000000..1edb11f
--- /dev/null
+++ b/java/com/google/turbine/types/Deannotate.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.types;
+
+import com.google.common.collect.ImmutableList;
+import com.google.turbine.type.Type;
+
+/** Removes type annotation metadata. */
+public class Deannotate {
+  public static Type deannotate(Type ty) {
+    switch (ty.tyKind()) {
+      case CLASS_TY:
+        return deannotateClassTy((Type.ClassTy) ty);
+      case ARRAY_TY:
+        return Type.ArrayTy.create(
+            deannotate(((Type.ArrayTy) ty).elementType()), ImmutableList.of());
+      case INTERSECTION_TY:
+        return Type.IntersectionTy.create(deannotate(((Type.IntersectionTy) ty).bounds()));
+      case WILD_TY:
+        return deannotateWildTy((Type.WildTy) ty);
+      case METHOD_TY:
+        return deannotateMethodTy((Type.MethodTy) ty);
+      case PRIM_TY:
+        return Type.PrimTy.create(((Type.PrimTy) ty).primkind(), ImmutableList.of());
+      case TY_VAR:
+        return Type.TyVar.create(((Type.TyVar) ty).sym(), ImmutableList.of());
+      case VOID_TY:
+      case ERROR_TY:
+      case NONE_TY:
+        return ty;
+    }
+    throw new AssertionError(ty.tyKind());
+  }
+
+  private static ImmutableList<Type> deannotate(ImmutableList<Type> types) {
+    ImmutableList.Builder<Type> result = ImmutableList.builder();
+    for (Type type : types) {
+      result.add(deannotate(type));
+    }
+    return result.build();
+  }
+
+  public static Type.ClassTy deannotateClassTy(Type.ClassTy ty) {
+    ImmutableList.Builder<Type.ClassTy.SimpleClassTy> classes = ImmutableList.builder();
+    for (Type.ClassTy.SimpleClassTy c : ty.classes()) {
+      classes.add(
+          Type.ClassTy.SimpleClassTy.create(c.sym(), deannotate(c.targs()), ImmutableList.of()));
+    }
+    return Type.ClassTy.create(classes.build());
+  }
+
+  private static Type deannotateWildTy(Type.WildTy ty) {
+    switch (ty.boundKind()) {
+      case NONE:
+        return Type.WildUnboundedTy.create(ImmutableList.of());
+      case LOWER:
+        return Type.WildLowerBoundedTy.create(ty.bound(), ImmutableList.of());
+      case UPPER:
+        return Type.WildUpperBoundedTy.create(ty.bound(), ImmutableList.of());
+    }
+    throw new AssertionError(ty.boundKind());
+  }
+
+  private static Type deannotateMethodTy(Type.MethodTy ty) {
+    return Type.MethodTy.create(
+        ty.tyParams(),
+        deannotate(ty.returnType()),
+        ty.receiverType() != null ? deannotate(ty.receiverType()) : null,
+        deannotate(ty.parameters()),
+        deannotate(ty.thrown()));
+  }
+
+  private Deannotate() {}
+}
diff --git a/java/com/google/turbine/types/Erasure.java b/java/com/google/turbine/types/Erasure.java
index 9042897..4b6fbc1 100644
--- a/java/com/google/turbine/types/Erasure.java
+++ b/java/com/google/turbine/types/Erasure.java
@@ -19,7 +19,6 @@
 import com.google.common.base.Function;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
-import com.google.turbine.binder.bound.SourceTypeBoundClass;
 import com.google.turbine.binder.bound.TypeBoundClass.TyVarInfo;
 import com.google.turbine.binder.sym.TyVarSymbol;
 import com.google.turbine.type.Type;
@@ -32,8 +31,8 @@
 import com.google.turbine.type.Type.WildTy;
 
 /** Generic type erasure. */
-public class Erasure {
-  public static Type erase(Type ty, Function<TyVarSymbol, SourceTypeBoundClass.TyVarInfo> tenv) {
+public final class Erasure {
+  public static Type erase(Type ty, Function<TyVarSymbol, TyVarInfo> tenv) {
     switch (ty.tyKind()) {
       case CLASS_TY:
         return eraseClassTy((Type.ClassTy) ty);
@@ -70,14 +69,12 @@
     return ty.bounds().isEmpty() ? ClassTy.OBJECT : erase(ty.bounds().get(0), tenv);
   }
 
-  private static Type eraseTyVar(
-      TyVar ty, Function<TyVarSymbol, SourceTypeBoundClass.TyVarInfo> tenv) {
-    SourceTypeBoundClass.TyVarInfo info = tenv.apply(ty.sym());
+  private static Type eraseTyVar(TyVar ty, Function<TyVarSymbol, TyVarInfo> tenv) {
+    TyVarInfo info = tenv.apply(ty.sym());
     return erase(info.upperBound(), tenv);
   }
 
-  private static Type.ArrayTy eraseArrayTy(
-      Type.ArrayTy ty, Function<TyVarSymbol, SourceTypeBoundClass.TyVarInfo> tenv) {
+  private static Type.ArrayTy eraseArrayTy(Type.ArrayTy ty, Function<TyVarSymbol, TyVarInfo> tenv) {
     return ArrayTy.create(erase(ty.elementType(), tenv), ty.annos());
   }
 
@@ -112,4 +109,6 @@
         erase(ty.parameters(), tenv),
         erase(ty.thrown(), tenv));
   }
+
+  private Erasure() {}
 }
diff --git a/java/com/google/turbine/zip/Zip.java b/java/com/google/turbine/zip/Zip.java
index 48d4697..fa0f0e0 100644
--- a/java/com/google/turbine/zip/Zip.java
+++ b/java/com/google/turbine/zip/Zip.java
@@ -71,7 +71,7 @@
  *       header is present only if ENDTOT in EOCD header is 0xFFFF.
  * </ul>
  */
-public class Zip {
+public final class Zip {
 
   static final int ZIP64_ENDSIG = 0x06064b50;
 
@@ -335,4 +335,6 @@
         && (buf.get(index + 2) == i)
         && (buf.get(index + 3) == j);
   }
+
+  private Zip() {}
 }
diff --git a/javatests/com/google/turbine/binder/BinderErrorTest.java b/javatests/com/google/turbine/binder/BinderErrorTest.java
index 15b54eb..e6e30cb 100644
--- a/javatests/com/google/turbine/binder/BinderErrorTest.java
+++ b/javatests/com/google/turbine/binder/BinderErrorTest.java
@@ -18,7 +18,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -93,6 +93,9 @@
           "<>:2: error: could not resolve element foo() in Anno", //
           "@Anno(foo=100, bar=200) class Test {}",
           "      ^",
+          "<>:2: error: could not resolve element bar() in Anno", //
+          "@Anno(foo=100, bar=200) class Test {}",
+          "               ^",
         },
       },
       {
@@ -558,9 +561,12 @@
           "class T {}",
         },
         {
-          "<>:7: error: could not resolve B", //
+          "<>:7: error: could not resolve B",
           "@One.A(b = {@B})",
           "             ^",
+          "<>:7: error: could not evaluate constant expression",
+          "@One.A(b = {@B})",
+          "           ^",
         },
       },
       {
@@ -700,6 +706,112 @@
           "                                     ^",
         },
       },
+      {
+        {
+          "import java.util.List;",
+          "class T {", //
+          "  List<int> xs = new ArrayList<>();",
+          "}",
+        },
+        {
+          "<>:3: error: unexpected type int", //
+          "  List<int> xs = new ArrayList<>();",
+          "          ^",
+        },
+      },
+      {
+        {
+          "@interface A {",
+          "  int[] xs() default {};",
+          "}",
+          "@A(xs = Object.class)",
+          "class T {",
+          "}",
+        },
+        {
+          "<>:4: error: could not evaluate constant expression",
+          "@A(xs = Object.class)",
+          "        ^",
+        },
+      },
+      {
+        {
+          "package foobar;",
+          "import java.lang.annotation.Retention;",
+          "@Retention",
+          "@interface Test {}",
+        },
+        {
+          "<>:3: error: missing required annotation argument: value", //
+          "@Retention",
+          "^",
+        },
+      },
+      {
+        {
+          "interface Test {", //
+          "  static final void f() {}",
+          "}",
+        },
+        {
+          "<>:2: error: unexpected modifier: final", //
+          "  static final void f() {}",
+          "                    ^",
+        },
+      },
+      {
+        {
+          "package foobar;",
+          "import java.lang.annotation.Retention;",
+          "@Retention",
+          "@Retention",
+          "@interface Test {}",
+        },
+        {
+          "<>:3: error: missing required annotation argument: value",
+          "@Retention",
+          "^",
+          "<>:4: error: missing required annotation argument: value",
+          "@Retention",
+          "^",
+          "<>:3: error: java.lang.annotation.Retention is not @Repeatable",
+          "@Retention",
+          "^",
+        },
+      },
+      {
+        {
+          "import java.util.List;", //
+          "class Test {",
+          "  @interface A {}",
+          "  void f(List<@NoSuch int> xs) {}",
+          "}",
+        },
+        {
+          "<>:4: error: could not resolve NoSuch",
+          "  void f(List<@NoSuch int> xs) {}",
+          "              ^",
+          "<>:4: error: unexpected type int",
+          "  void f(List<@NoSuch int> xs) {}",
+          "                         ^",
+        },
+      },
+      {
+        {
+          "@interface B {}",
+          "@interface A {",
+          "  B[] value() default @B;",
+          "}",
+          "interface C {}",
+          "@A(value = @C)",
+          "class T {}",
+        },
+        {
+          "<>:6: error: C is not an annotation", //
+          "@A(value = @C)",
+          "            ^",
+        },
+      },
     };
     return Arrays.asList((Object[][]) testCases);
   }
@@ -714,17 +826,18 @@
 
   @Test
   public void test() throws Exception {
-    try {
-      Binder.bind(
-              ImmutableList.of(parseLines(source)),
-              ClassPathBinder.bindClasspath(ImmutableList.of()),
-              TURBINE_BOOTCLASSPATH,
-              /* moduleVersion=*/ Optional.empty())
-          .units();
-      fail(Joiner.on('\n').join(source));
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().isEqualTo(lines(expected));
-    }
+    TurbineError e =
+        assertThrows(
+            Joiner.on('\n').join(source),
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                        ImmutableList.of(parseLines(source)),
+                        ClassPathBinder.bindClasspath(ImmutableList.of()),
+                        TURBINE_BOOTCLASSPATH,
+                        /* moduleVersion=*/ Optional.empty())
+                    .units());
+    assertThat(e).hasMessageThat().isEqualTo(lines(expected));
   }
 
   @SupportedAnnotationTypes("*")
@@ -744,22 +857,23 @@
   // exercise error reporting with annotation enabled, which should be identical
   @Test
   public void testWithProcessors() throws Exception {
-    try {
-      Binder.bind(
-              ImmutableList.of(parseLines(source)),
-              ClassPathBinder.bindClasspath(ImmutableList.of()),
-              ProcessorInfo.create(
-                  ImmutableList.of(new HelloWorldProcessor()),
-                  /* loader= */ getClass().getClassLoader(),
-                  /* options= */ ImmutableMap.of(),
-                  SourceVersion.latestSupported()),
-              TURBINE_BOOTCLASSPATH,
-              /* moduleVersion=*/ Optional.empty())
-          .units();
-      fail(Joiner.on('\n').join(source));
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().isEqualTo(lines(expected));
-    }
+    TurbineError e =
+        assertThrows(
+            Joiner.on('\n').join(source),
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                        ImmutableList.of(parseLines(source)),
+                        ClassPathBinder.bindClasspath(ImmutableList.of()),
+                        ProcessorInfo.create(
+                            ImmutableList.of(new HelloWorldProcessor()),
+                            /* loader= */ getClass().getClassLoader(),
+                            /* options= */ ImmutableMap.of(),
+                            SourceVersion.latestSupported()),
+                        TURBINE_BOOTCLASSPATH,
+                        /* moduleVersion=*/ Optional.empty())
+                    .units());
+    assertThat(e).hasMessageThat().isEqualTo(lines(expected));
   }
 
   private static CompUnit parseLines(String... lines) {
diff --git a/javatests/com/google/turbine/binder/BinderTest.java b/javatests/com/google/turbine/binder/BinderTest.java
index e238ee0..820fe22 100644
--- a/javatests/com/google/turbine/binder/BinderTest.java
+++ b/javatests/com/google/turbine/binder/BinderTest.java
@@ -19,7 +19,8 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
-import static org.junit.Assert.fail;
+import static java.util.Objects.requireNonNull;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -84,17 +85,16 @@
             new ClassSymbol("a/A$Inner2"),
             new ClassSymbol("b/B"));
 
-    SourceTypeBoundClass a = bound.get(new ClassSymbol("a/A"));
+    SourceTypeBoundClass a = getBoundClass(bound, "a/A");
     assertThat(a.superclass()).isEqualTo(new ClassSymbol("java/lang/Object"));
     assertThat(a.interfaces()).isEmpty();
 
-    assertThat(bound.get(new ClassSymbol("a/A$Inner1")).superclass())
-        .isEqualTo(new ClassSymbol("b/B"));
+    assertThat(getBoundClass(bound, "a/A$Inner1").superclass()).isEqualTo(new ClassSymbol("b/B"));
 
-    assertThat(bound.get(new ClassSymbol("a/A$Inner2")).superclass())
+    assertThat(getBoundClass(bound, "a/A$Inner2").superclass())
         .isEqualTo(new ClassSymbol("a/A$Inner1"));
 
-    SourceTypeBoundClass b = bound.get(new ClassSymbol("b/B"));
+    SourceTypeBoundClass b = getBoundClass(bound, "b/B");
     assertThat(b.superclass()).isEqualTo(new ClassSymbol("a/A"));
   }
 
@@ -129,12 +129,12 @@
             new ClassSymbol("b/B"),
             new ClassSymbol("b/B$BInner"));
 
-    assertThat(bound.get(new ClassSymbol("b/B")).interfaces())
+    assertThat(getBoundClass(bound, "b/B").interfaces())
         .containsExactly(new ClassSymbol("com/i/I"));
 
-    assertThat(bound.get(new ClassSymbol("b/B$BInner")).superclass())
+    assertThat(getBoundClass(bound, "b/B$BInner").superclass())
         .isEqualTo(new ClassSymbol("com/i/I$IInner"));
-    assertThat(bound.get(new ClassSymbol("b/B$BInner")).interfaces()).isEmpty();
+    assertThat(getBoundClass(bound, "b/B$BInner").interfaces()).isEmpty();
   }
 
   @Test
@@ -161,7 +161,7 @@
                 /* moduleVersion=*/ Optional.empty())
             .units();
 
-    assertThat(bound.get(new ClassSymbol("other/Foo")).superclass())
+    assertThat(getBoundClass(bound, "other/Foo").superclass())
         .isEqualTo(new ClassSymbol("com/test/Test$Inner"));
   }
 
@@ -182,16 +182,16 @@
                 "  class Inner {}",
                 "}"));
 
-    try {
-      Binder.bind(
-          units,
-          ClassPathBinder.bindClasspath(ImmutableList.of()),
-          TURBINE_BOOTCLASSPATH,
-          /* moduleVersion=*/ Optional.empty());
-      fail();
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().contains("cycle in class hierarchy: a.A -> b.B -> a.A");
-    }
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    TURBINE_BOOTCLASSPATH,
+                    /* moduleVersion=*/ Optional.empty()));
+    assertThat(e).hasMessageThat().contains("cycle in class hierarchy: a.A -> b.B -> a.A");
   }
 
   @Test
@@ -211,7 +211,7 @@
                 /* moduleVersion=*/ Optional.empty())
             .units();
 
-    SourceTypeBoundClass a = bound.get(new ClassSymbol("com/test/Annotation"));
+    SourceTypeBoundClass a = getBoundClass(bound, "com/test/Annotation");
     assertThat(a.access())
         .isEqualTo(
             TurbineFlag.ACC_PUBLIC
@@ -240,7 +240,7 @@
                 /* moduleVersion=*/ Optional.empty())
             .units();
 
-    SourceTypeBoundClass a = bound.get(new ClassSymbol("a/A"));
+    SourceTypeBoundClass a = getBoundClass(bound, "a/A");
     assertThat(a.interfaces()).containsExactly(new ClassSymbol("java/util/Map$Entry"));
   }
 
@@ -259,7 +259,7 @@
     try (OutputStream os = Files.newOutputStream(libJar);
         JarOutputStream jos = new JarOutputStream(os)) {
       jos.putNextEntry(new JarEntry("B.class"));
-      jos.write(lib.get("B"));
+      jos.write(requireNonNull(lib.get("B")));
     }
 
     ImmutableList<Tree.CompUnit> units =
@@ -280,7 +280,7 @@
                 /* moduleVersion=*/ Optional.empty())
             .units();
 
-    SourceTypeBoundClass a = bound.get(new ClassSymbol("C$A"));
+    SourceTypeBoundClass a = getBoundClass(bound, "C$A");
     assertThat(a.annotationMetadata().target()).containsExactly(TurbineElementType.TYPE_USE);
   }
 
@@ -306,7 +306,7 @@
 
     assertThat(bound.keySet()).containsExactly(new ClassSymbol("a/A"));
 
-    SourceTypeBoundClass a = bound.get(new ClassSymbol("a/A"));
+    SourceTypeBoundClass a = getBoundClass(bound, "a/A");
     FieldInfo f = getOnlyElement(a.fields());
     assertThat(f.name()).isEqualTo("b");
     assertThat(f.value()).isNull();
@@ -315,4 +315,10 @@
   private Tree.CompUnit parseLines(String... lines) {
     return Parser.parse(Joiner.on('\n').join(lines));
   }
+
+  private static SourceTypeBoundClass getBoundClass(
+      Map<ClassSymbol, SourceTypeBoundClass> bound, String name) {
+    // requireNonNull is safe as long as we call this method with classes that exist in our sources.
+    return requireNonNull(bound.get(new ClassSymbol(name)));
+  }
 }
diff --git a/javatests/com/google/turbine/binder/ClassPathBinderTest.java b/javatests/com/google/turbine/binder/ClassPathBinderTest.java
index c11d814..6c6bc3e 100644
--- a/javatests/com/google/turbine/binder/ClassPathBinderTest.java
+++ b/javatests/com/google/turbine/binder/ClassPathBinderTest.java
@@ -16,17 +16,22 @@
 
 package com.google.turbine.binder;
 
+import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.Iterables.getLast;
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
+import static com.google.turbine.testing.TestResources.getResourceBytes;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
+import static java.util.Locale.ENGLISH;
+import static java.util.Objects.requireNonNull;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.VerifyException;
+import com.google.common.collect.ImmutableCollection;
 import com.google.common.collect.ImmutableList;
-import com.google.common.io.ByteStreams;
 import com.google.common.io.MoreFiles;
 import com.google.turbine.binder.bound.EnumConstantValue;
 import com.google.turbine.binder.bound.TypeBoundClass;
@@ -34,6 +39,7 @@
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.lookup.LookupKey;
 import com.google.turbine.binder.lookup.LookupResult;
+import com.google.turbine.binder.lookup.PackageScope;
 import com.google.turbine.binder.lookup.Scope;
 import com.google.turbine.binder.sym.ClassSymbol;
 import com.google.turbine.binder.sym.FieldSymbol;
@@ -42,40 +48,100 @@
 import com.google.turbine.tree.Tree.Ident;
 import com.google.turbine.type.AnnoInfo;
 import com.google.turbine.type.Type.ClassTy;
-import java.io.IOError;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Arrays;
 import java.util.jar.JarEntry;
 import java.util.jar.JarOutputStream;
+import javax.tools.StandardJavaFileManager;
+import javax.tools.StandardLocation;
+import javax.tools.ToolProvider;
 import org.junit.Rule;
 import org.junit.Test;
 import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
+import org.junit.runners.Parameterized;
 
-@RunWith(JUnit4.class)
+@RunWith(Parameterized.class)
 public class ClassPathBinderTest {
 
+  @Parameterized.Parameters
+  public static ImmutableCollection<Object[]> parameters() {
+    Object[] testCases = {
+      TURBINE_BOOTCLASSPATH,
+      FileManagerClassBinder.adapt(
+          ToolProvider.getSystemJavaCompiler().getStandardFileManager(null, ENGLISH, UTF_8),
+          StandardLocation.PLATFORM_CLASS_PATH),
+    };
+    return Arrays.stream(testCases).map(x -> new Object[] {x}).collect(toImmutableList());
+  }
+
+  private final ClassPath classPath;
+
+  public ClassPathBinderTest(ClassPath classPath) {
+    this.classPath = classPath;
+  }
+
   @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
 
+  private static Ident ident(String string) {
+    return new Ident(/* position= */ -1, string);
+  }
+
   @Test
-  public void classPathLookup() throws IOException {
+  public void classPathLookup() {
 
-    Scope javaLang = TURBINE_BOOTCLASSPATH.index().lookupPackage(ImmutableList.of("java", "lang"));
+    Scope javaLang = classPath.index().lookupPackage(ImmutableList.of("java", "lang"));
 
-    LookupResult result = javaLang.lookup(new LookupKey(ImmutableList.of(new Ident(-1, "String"))));
+    final String string = "String";
+    LookupResult result = javaLang.lookup(new LookupKey(ImmutableList.of(ident(string))));
     assertThat(result.remaining()).isEmpty();
     assertThat(result.sym()).isEqualTo(new ClassSymbol("java/lang/String"));
 
-    result = javaLang.lookup(new LookupKey(ImmutableList.of(new Ident(-1, "Object"))));
+    result = javaLang.lookup(new LookupKey(ImmutableList.of(ident("Object"))));
     assertThat(result.remaining()).isEmpty();
     assertThat(result.sym()).isEqualTo(new ClassSymbol("java/lang/Object"));
   }
 
   @Test
-  public void classPathClasses() throws IOException {
-    Env<ClassSymbol, BytecodeBoundClass> env = TURBINE_BOOTCLASSPATH.env();
+  public void packageScope() {
+
+    PackageScope result = classPath.index().lookupPackage(ImmutableList.of("java", "nosuch"));
+    assertThat(result).isNull();
+
+    result = classPath.index().lookupPackage(ImmutableList.of("java", "lang"));
+    assertThat(result.classes()).contains(new ClassSymbol("java/lang/String"));
+
+    assertThat(result.lookup(new LookupKey(ImmutableList.of(ident("NoSuch"))))).isNull();
+  }
+
+  @Test
+  public void scope() {
+    Scope scope = classPath.index().scope();
+    LookupResult result;
+
+    result =
+        scope.lookup(
+            new LookupKey(
+                ImmutableList.of(ident("java"), ident("util"), ident("Map"), ident("Entry"))));
+    assertThat(result.sym()).isEqualTo(new ClassSymbol("java/util/Map"));
+    assertThat(result.remaining().stream().map(Ident::value)).containsExactly("Entry");
+
+    result =
+        scope.lookup(new LookupKey(ImmutableList.of(ident("java"), ident("util"), ident("Map"))));
+    assertThat(result.sym()).isEqualTo(new ClassSymbol("java/util/Map"));
+    assertThat(result.remaining()).isEmpty();
+
+    result =
+        scope.lookup(
+            new LookupKey(ImmutableList.of(ident("java"), ident("util"), ident("NoSuch"))));
+    assertThat(result).isNull();
+  }
+
+  @Test
+  public void classPathClasses() {
+    Env<ClassSymbol, BytecodeBoundClass> env = classPath.env();
 
     TypeBoundClass c = env.get(new ClassSymbol("java/util/Map$Entry"));
     assertThat(c.owner()).isEqualTo(new ClassSymbol("java/util/Map"));
@@ -96,7 +162,7 @@
 
   @Test
   public void interfaces() {
-    Env<ClassSymbol, BytecodeBoundClass> env = TURBINE_BOOTCLASSPATH.env();
+    Env<ClassSymbol, BytecodeBoundClass> env = classPath.env();
 
     TypeBoundClass c = env.get(new ClassSymbol("java/lang/annotation/Retention"));
     assertThat(c.interfaceTypes()).hasSize(1);
@@ -114,7 +180,7 @@
 
   @Test
   public void annotations() {
-    Env<ClassSymbol, BytecodeBoundClass> env = TURBINE_BOOTCLASSPATH.env();
+    Env<ClassSymbol, BytecodeBoundClass> env = classPath.env();
     TypeBoundClass c = env.get(new ClassSymbol("java/lang/annotation/Retention"));
 
     AnnoInfo anno =
@@ -122,34 +188,25 @@
             .filter(a -> a.sym().equals(new ClassSymbol("java/lang/annotation/Retention")))
             .collect(onlyElement());
     assertThat(anno.values().keySet()).containsExactly("value");
-    assertThat(((EnumConstantValue) anno.values().get("value")).sym())
+    // requireNonNull is safe because we checked that the keySet contains `"value"`.
+    assertThat(((EnumConstantValue) requireNonNull(anno.values().get("value"))).sym())
         .isEqualTo(
             new FieldSymbol(new ClassSymbol("java/lang/annotation/RetentionPolicy"), "RUNTIME"));
   }
 
   @Test
   public void byteCodeBoundClassName() {
+    Env<ClassSymbol, BytecodeBoundClass> env = classPath.env();
     BytecodeBoundClass c =
         new BytecodeBoundClass(
             new ClassSymbol("java/util/List"),
-            () -> {
-              try {
-                return ByteStreams.toByteArray(
-                    getClass().getClassLoader().getResourceAsStream("java/util/ArrayList.class"));
-              } catch (IOException e) {
-                throw new IOError(e);
-              }
-            },
-            null,
+            () -> getResourceBytes(getClass(), "/java/util/ArrayList.class"),
+            env,
             null);
-    try {
-      c.owner();
-      fail();
-    } catch (VerifyException e) {
-      assertThat(e)
-          .hasMessageThat()
-          .contains("expected class data for java/util/List, saw java/util/ArrayList instead");
-    }
+    VerifyException e = assertThrows(VerifyException.class, () -> c.owner());
+    assertThat(e)
+        .hasMessageThat()
+        .contains("expected class data for java/util/List, saw java/util/ArrayList instead");
   }
 
   @Test
@@ -157,12 +214,9 @@
     Path lib = temporaryFolder.newFile("NOT_A_JAR").toPath();
     MoreFiles.asCharSink(lib, UTF_8).write("hello");
 
-    try {
-      ClassPathBinder.bindClasspath(ImmutableList.of(lib));
-      fail();
-    } catch (IOException e) {
-      assertThat(e).hasMessageThat().contains("NOT_A_JAR");
-    }
+    IOException e =
+        assertThrows(IOException.class, () -> ClassPathBinder.bindClasspath(ImmutableList.of(lib)));
+    assertThat(e).hasMessageThat().contains("NOT_A_JAR");
   }
 
   @Test
@@ -178,4 +232,21 @@
     assertThat(new String(classPath.resource("foo/bar/hello.txt").get(), UTF_8)).isEqualTo("hello");
     assertThat(classPath.resource("foo/bar/Baz.class")).isNull();
   }
+
+  @Test
+  public void resourcesFileManager() throws Exception {
+    Path path = temporaryFolder.newFile("tmp.jar").toPath();
+    try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(path))) {
+      jos.putNextEntry(new JarEntry("foo/bar/hello.txt"));
+      jos.write("hello".getBytes(UTF_8));
+      jos.putNextEntry(new JarEntry("foo/bar/Baz.class"));
+      jos.write("goodbye".getBytes(UTF_8));
+    }
+    StandardJavaFileManager fileManager =
+        ToolProvider.getSystemJavaCompiler().getStandardFileManager(null, ENGLISH, UTF_8);
+    fileManager.setLocation(StandardLocation.CLASS_PATH, ImmutableList.of(path.toFile()));
+    ClassPath classPath = FileManagerClassBinder.adapt(fileManager, StandardLocation.CLASS_PATH);
+    assertThat(new String(classPath.resource("foo/bar/hello.txt").get(), UTF_8)).isEqualTo("hello");
+    assertThat(classPath.resource("foo/bar/NoSuch.class")).isNull();
+  }
 }
diff --git a/javatests/com/google/turbine/binder/CtSymClassBinderTest.java b/javatests/com/google/turbine/binder/CtSymClassBinderTest.java
new file mode 100644
index 0000000..2da9f4c
--- /dev/null
+++ b/javatests/com/google/turbine/binder/CtSymClassBinderTest.java
@@ -0,0 +1,48 @@
+/*
+ * Copyright 2020 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class CtSymClassBinderTest {
+  @Test
+  public void formatReleaseVersion() {
+    ImmutableList.of("5", "6", "7", "8", "9")
+        .forEach(x -> assertThat(CtSymClassBinder.formatReleaseVersion(x)).isEqualTo(x));
+    ImmutableMap.of(
+            "10", "A",
+            "11", "B",
+            "12", "C",
+            "35", "Z")
+        .forEach((k, v) -> assertThat(CtSymClassBinder.formatReleaseVersion(k)).isEqualTo(v));
+    ImmutableList.of("4", "36")
+        .forEach(
+            x ->
+                assertThrows(
+                    x,
+                    IllegalArgumentException.class,
+                    () -> CtSymClassBinder.formatReleaseVersion(x)));
+  }
+}
diff --git a/javatests/com/google/turbine/binder/ProcessingTest.java b/javatests/com/google/turbine/binder/ProcessingTest.java
new file mode 100644
index 0000000..b7091e8
--- /dev/null
+++ b/javatests/com/google/turbine/binder/ProcessingTest.java
@@ -0,0 +1,83 @@
+/*
+ * Copyright 2020 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.binder;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.junit.Assert.assertThrows;
+
+import com.google.common.collect.ImmutableList;
+import javax.lang.model.SourceVersion;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ProcessingTest {
+  @Test
+  public void parseSourceVersion() {
+    assertThat(Processing.parseSourceVersion(ImmutableList.of()))
+        .isEqualTo(SourceVersion.latestSupported());
+    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "8", "-target", "11")))
+        .isEqualTo(SourceVersion.RELEASE_8);
+    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "8", "-source", "7")))
+        .isEqualTo(SourceVersion.RELEASE_7);
+  }
+
+  @Test
+  public void withPrefix() {
+    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "1.7")))
+        .isEqualTo(SourceVersion.RELEASE_7);
+    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", "1.8")))
+        .isEqualTo(SourceVersion.RELEASE_8);
+  }
+
+  @Test
+  public void invalidPrefix() {
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> Processing.parseSourceVersion(ImmutableList.of("-source", "1.11")));
+    assertThat(expected).hasMessageThat().contains("invalid -source version: 1.11");
+  }
+
+  @Test
+  public void latestSupported() {
+    String latest = SourceVersion.latestSupported().toString();
+    assertThat(latest).startsWith("RELEASE_");
+    latest = latest.substring("RELEASE_".length());
+    assertThat(Processing.parseSourceVersion(ImmutableList.of("-source", latest)))
+        .isEqualTo(SourceVersion.latestSupported());
+  }
+
+  @Test
+  public void missingArgument() {
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> Processing.parseSourceVersion(ImmutableList.of("-source")));
+    assertThat(expected).hasMessageThat().contains("-source requires an argument");
+  }
+
+  @Test
+  public void invalidSourceVersion() {
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () -> Processing.parseSourceVersion(ImmutableList.of("-source", "NOSUCH")));
+    assertThat(expected).hasMessageThat().contains("invalid -source version: NOSUCH");
+  }
+}
diff --git a/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java b/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java
index 3e841a5..ec2ebbf 100644
--- a/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java
+++ b/javatests/com/google/turbine/binder/bytecode/BytecodeBoundClassTest.java
@@ -17,6 +17,7 @@
 package com.google.turbine.binder.bytecode;
 
 import static com.google.common.collect.Iterables.getLast;
+import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
@@ -31,6 +32,7 @@
 import com.google.turbine.binder.env.CompoundEnv;
 import com.google.turbine.binder.env.Env;
 import com.google.turbine.binder.sym.ClassSymbol;
+import com.google.turbine.model.TurbineFlag;
 import com.google.turbine.type.Type;
 import com.google.turbine.type.Type.ClassTy;
 import java.io.IOException;
@@ -40,6 +42,7 @@
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import org.checkerframework.checker.nullness.qual.Nullable;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
@@ -81,9 +84,11 @@
         .isEqualTo(new ClassSymbol("java/lang/String"));
   }
 
+  @SuppressWarnings({"deprecation", "TypeNameShadowing", "InlineMeSuggester"})
   static class HasMethod {
     @Deprecated
-    <X, Y extends X, Z extends Throwable> X foo(@Deprecated X bar, Y baz) throws IOException, Z {
+    <X, Y extends X, Z extends Throwable> @Nullable X foo(@Deprecated X bar, Y baz)
+        throws IOException, Z {
       return null;
     }
 
@@ -175,6 +180,19 @@
     assertThat(getBytecodeBoundClass(C.class, B.class, A.class).methods()).hasSize(1);
   }
 
+  interface D {
+    default void f() {}
+  }
+
+  @Test
+  public void defaultMethods() {
+    assertThat(
+            (getOnlyElement(getBytecodeBoundClass(D.class).methods()).access()
+                    & TurbineFlag.ACC_DEFAULT)
+                == TurbineFlag.ACC_DEFAULT)
+        .isTrue();
+  }
+
   private static byte[] toByteArrayOrDie(InputStream is) {
     try {
       return ByteStreams.toByteArray(is);
@@ -201,7 +219,7 @@
             .append(
                 new Env<ClassSymbol, BytecodeBoundClass>() {
                   @Override
-                  public BytecodeBoundClass get(ClassSymbol sym) {
+                  public @Nullable BytecodeBoundClass get(ClassSymbol sym) {
                     return map.get(sym);
                   }
                 });
diff --git a/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java b/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java
index 022e47c..861bfef 100644
--- a/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java
+++ b/javatests/com/google/turbine/binder/lookup/TopLevelIndexTest.java
@@ -18,7 +18,7 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.turbine.binder.sym.ClassSymbol;
@@ -105,15 +105,8 @@
 
   @Test
   public void emptyLookup() {
-    LookupKey key = lookupKey(ImmutableList.of("java", "util", "List"));
-    key = key.rest();
-    key = key.rest();
-    try {
-      key.rest();
-      fail("expected exception");
-    } catch (NoSuchElementException e) {
-      // expected
-    }
+    LookupKey key = lookupKey(ImmutableList.of("java", "util", "List")).rest().rest();
+    assertThrows(NoSuchElementException.class, () -> key.rest());
   }
 
   private LookupKey lookupKey(ImmutableList<String> names) {
diff --git a/javatests/com/google/turbine/bytecode/ClassReaderTest.java b/javatests/com/google/turbine/bytecode/ClassReaderTest.java
index fb64541..9a9fdb1 100644
--- a/javatests/com/google/turbine/bytecode/ClassReaderTest.java
+++ b/javatests/com/google/turbine/bytecode/ClassReaderTest.java
@@ -18,6 +18,7 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Strings;
 import com.google.common.collect.Iterables;
@@ -34,6 +35,8 @@
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
 import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Attribute;
+import org.objectweb.asm.ByteVector;
 import org.objectweb.asm.ClassWriter;
 import org.objectweb.asm.FieldVisitor;
 import org.objectweb.asm.ModuleVisitor;
@@ -136,7 +139,7 @@
     assertThat(annotation.typeName()).isEqualTo("Ljava/lang/annotation/Retention;");
     assertThat(annotation.elementValuePairs()).hasSize(1);
     assertThat(annotation.elementValuePairs()).containsKey("value");
-    ElementValue value = annotation.elementValuePairs().get("value");
+    ElementValue value = requireNonNull(annotation.elementValuePairs().get("value"));
     assertThat(value.kind()).isEqualTo(ElementValue.Kind.ENUM);
     ElementValue.EnumConstValue enumValue = (ElementValue.EnumConstValue) value;
     assertThat(enumValue.typeName()).isEqualTo("Ljava/lang/annotation/RetentionPolicy;");
@@ -335,4 +338,28 @@
     assertThat(p2.descriptor()).isEqualTo("p2");
     assertThat(p2.implDescriptors()).containsExactly("p2i1", "p2i2", "p2i3");
   }
+
+  @Test
+  public void transitiveJar() {
+    ClassWriter cw = new ClassWriter(0);
+    cw.visit(
+        52,
+        Opcodes.ACC_PUBLIC | Opcodes.ACC_FINAL | Opcodes.ACC_SUPER,
+        "Hello",
+        null,
+        "java/lang/Object",
+        null);
+    cw.visitAttribute(
+        new Attribute("TurbineTransitiveJar") {
+          @Override
+          protected ByteVector write(
+              ClassWriter classWriter, byte[] code, int codeLength, int maxStack, int maxLocals) {
+            ByteVector result = new ByteVector();
+            result.putShort(classWriter.newUTF8("path/to/transitive.jar"));
+            return result;
+          }
+        });
+    ClassFile cf = ClassReader.read(null, cw.toByteArray());
+    assertThat(cf.transitiveJar()).isEqualTo("path/to/transitive.jar");
+  }
 }
diff --git a/javatests/com/google/turbine/bytecode/ClassWriterTest.java b/javatests/com/google/turbine/bytecode/ClassWriterTest.java
index 71cf356..f488bbe 100644
--- a/javatests/com/google/turbine/bytecode/ClassWriterTest.java
+++ b/javatests/com/google/turbine/bytecode/ClassWriterTest.java
@@ -90,7 +90,8 @@
     byte[] original = Files.readAllBytes(out.resolve("test/Test.class"));
     byte[] actual = ClassWriter.writeClass(ClassReader.read(null, original));
 
-    assertThat(AsmUtils.textify(original)).isEqualTo(AsmUtils.textify(actual));
+    assertThat(AsmUtils.textify(original, /* skipDebug= */ true))
+        .isEqualTo(AsmUtils.textify(actual, /* skipDebug= */ true));
   }
 
   // Test that >Short.MAX_VALUE constants round-trip through the constant pool.
@@ -145,10 +146,12 @@
     byte[] inputBytes = cw.toByteArray();
     byte[] outputBytes = ClassWriter.writeClass(ClassReader.read("module-info", inputBytes));
 
-    assertThat(AsmUtils.textify(inputBytes)).isEqualTo(AsmUtils.textify(outputBytes));
+    assertThat(AsmUtils.textify(inputBytes, /* skipDebug= */ true))
+        .isEqualTo(AsmUtils.textify(outputBytes, /* skipDebug= */ true));
 
     // test a round trip
     outputBytes = ClassWriter.writeClass(ClassReader.read("module-info", outputBytes));
-    assertThat(AsmUtils.textify(inputBytes)).isEqualTo(AsmUtils.textify(outputBytes));
+    assertThat(AsmUtils.textify(inputBytes, /* skipDebug= */ true))
+        .isEqualTo(AsmUtils.textify(outputBytes, /* skipDebug= */ true));
   }
 }
diff --git a/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java b/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java
index f3ab8e7..8602fe5 100644
--- a/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java
+++ b/javatests/com/google/turbine/bytecode/sig/SigIntegrationTest.java
@@ -17,6 +17,7 @@
 package com.google.turbine.bytecode.sig;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.io.MoreFiles.getFileExtension;
 import static com.google.common.truth.Truth.assertThat;
 
 import com.google.common.base.Splitter;
@@ -70,7 +71,7 @@
             Stream<Path> stream = Files.walk(jarfs.getPath("/"))) {
           stream
               .filter(Files::isRegularFile)
-              .filter(p -> p.getFileName().toString().endsWith(".class"))
+              .filter(p -> getFileExtension(p).equals("class"))
               .forEachOrdered(consumer);
         }
       }
@@ -80,7 +81,7 @@
       Map<String, ?> env = new HashMap<>();
       try (FileSystem fileSystem = FileSystems.newFileSystem(URI.create("jrt:/"), env);
           Stream<Path> stream = Files.walk(fileSystem.getPath("/modules"))) {
-        stream.filter(p -> p.getFileName().toString().endsWith(".class")).forEachOrdered(consumer);
+        stream.filter(p -> getFileExtension(p).equals("class")).forEachOrdered(consumer);
       }
     }
   }
@@ -93,7 +94,7 @@
           try {
             new ClassReader(Files.newInputStream(path))
                 .accept(
-                    new ClassVisitor(Opcodes.ASM7) {
+                    new ClassVisitor(Opcodes.ASM9) {
                       @Override
                       public void visit(
                           int version,
diff --git a/javatests/com/google/turbine/deps/AbstractTransitiveTest.java b/javatests/com/google/turbine/deps/AbstractTransitiveTest.java
deleted file mode 100644
index c5b68ff..0000000
--- a/javatests/com/google/turbine/deps/AbstractTransitiveTest.java
+++ /dev/null
@@ -1,263 +0,0 @@
-/*
- * Copyright 2016 Google Inc. All Rights Reserved.
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- *     http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-package com.google.turbine.deps;
-
-import static com.google.common.collect.ImmutableList.toImmutableList;
-import static com.google.common.collect.Iterables.getOnlyElement;
-import static com.google.common.truth.Truth.assertThat;
-import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
-import static java.nio.charset.StandardCharsets.UTF_8;
-
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Iterables;
-import com.google.common.io.ByteStreams;
-import com.google.turbine.bytecode.ClassFile;
-import com.google.turbine.bytecode.ClassFile.InnerClass;
-import com.google.turbine.bytecode.ClassReader;
-import com.google.turbine.main.Main;
-import java.io.IOException;
-import java.io.OutputStream;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Arrays;
-import java.util.Enumeration;
-import java.util.LinkedHashMap;
-import java.util.Map;
-import java.util.jar.JarEntry;
-import java.util.jar.JarFile;
-import java.util.jar.JarOutputStream;
-import org.junit.Rule;
-import org.junit.Test;
-import org.junit.rules.TemporaryFolder;
-import org.objectweb.asm.ClassWriter;
-import org.objectweb.asm.Opcodes;
-
-public abstract class AbstractTransitiveTest {
-
-  protected abstract Path runTurbine(ImmutableList<Path> sources, ImmutableList<Path> classpath)
-      throws IOException;
-
-  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
-
-  class SourceBuilder {
-    private final Path lib;
-    private final ImmutableList.Builder<Path> sources = ImmutableList.builder();
-
-    SourceBuilder() throws IOException {
-      lib = temporaryFolder.newFolder().toPath();
-    }
-
-    SourceBuilder addSourceLines(String name, String... lines) throws IOException {
-      Path path = lib.resolve(name);
-      Files.createDirectories(path.getParent());
-      Files.write(path, Arrays.asList(lines), UTF_8);
-      sources.add(path);
-      return this;
-    }
-
-    ImmutableList<Path> build() {
-      return sources.build();
-    }
-  }
-
-  private Map<String, byte[]> readJar(Path libb) throws IOException {
-    Map<String, byte[]> jarEntries = new LinkedHashMap<>();
-    try (JarFile jf = new JarFile(libb.toFile())) {
-      Enumeration<JarEntry> entries = jf.entries();
-      while (entries.hasMoreElements()) {
-        JarEntry je = entries.nextElement();
-        jarEntries.put(je.getName(), ByteStreams.toByteArray(jf.getInputStream(je)));
-      }
-    }
-    return jarEntries;
-  }
-
-  @Test
-  public void transitive() throws Exception {
-    Path liba =
-        runTurbine(
-            new SourceBuilder()
-                .addSourceLines(
-                    "a/A.java",
-                    "package a;",
-                    "import java.util.Map;",
-                    "public class A {",
-                    "  public @interface Anno {",
-                    "    int x() default 42;",
-                    "  }",
-                    "  public static class Inner {}",
-                    "  public static final int CONST = 42;",
-                    "  public int mutable = 42;",
-                    "  public Map.Entry<String, String> f(Map<String, String> m) {",
-                    "    return m.entrySet().iterator().next();",
-                    "  }",
-                    "}")
-                .build(),
-            ImmutableList.of());
-
-    Path libb =
-        runTurbine(
-            new SourceBuilder()
-                .addSourceLines("b/B.java", "package b;", "public class B extends a.A {}")
-                .build(),
-            ImmutableList.of(liba));
-
-    // libb repackages A, and any member types
-    assertThat(readJar(libb).keySet())
-        .containsExactly(
-            "b/B.class",
-            "META-INF/TRANSITIVE/a/A.class",
-            "META-INF/TRANSITIVE/a/A$Anno.class",
-            "META-INF/TRANSITIVE/a/A$Inner.class");
-
-    ClassFile a = ClassReader.read(null, readJar(libb).get("META-INF/TRANSITIVE/a/A.class"));
-    // methods and non-constant fields are removed
-    assertThat(getOnlyElement(a.fields()).name()).isEqualTo("CONST");
-    assertThat(a.methods()).isEmpty();
-    assertThat(Iterables.transform(a.innerClasses(), InnerClass::innerClass))
-        .containsExactly("a/A$Anno", "a/A$Inner");
-
-    // annotation interface methods are preserved
-    assertThat(
-            ClassReader.read(null, readJar(libb).get("META-INF/TRANSITIVE/a/A$Anno.class"))
-                .methods())
-        .hasSize(1);
-
-    // A class that references members of the transitive supertype A by simple name
-    // compiles cleanly against the repackaged version of A.
-    // Explicitly use turbine; javac-turbine doesn't support direct-classpath compilations.
-
-    Path libc = temporaryFolder.newFolder().toPath().resolve("out.jar");
-    ImmutableList<String> sources =
-        new SourceBuilder()
-                .addSourceLines(
-                    "c/C.java",
-                    "package c;",
-                    "public class C extends b.B {",
-                    "  @Anno(x = 2) static final Inner i; // a.A$Inner ",
-                    "  static final int X = CONST; // a.A#CONST",
-                    "}")
-                .build()
-                .stream()
-                .map(Path::toString)
-                .collect(toImmutableList());
-    Main.compile(
-        optionsWithBootclasspath()
-            .setSources(sources)
-            .setClassPath(
-                ImmutableList.of(libb).stream().map(Path::toString).collect(toImmutableList()))
-            .setOutput(libc.toString())
-            .build());
-
-    assertThat(readJar(libc).keySet())
-        .containsExactly(
-            "c/C.class",
-            "META-INF/TRANSITIVE/b/B.class",
-            "META-INF/TRANSITIVE/a/A.class",
-            "META-INF/TRANSITIVE/a/A$Anno.class",
-            "META-INF/TRANSITIVE/a/A$Inner.class");
-  }
-
-  @Test
-  public void anonymous() throws Exception {
-    Path liba = temporaryFolder.newFolder().toPath().resolve("out.jar");
-    try (OutputStream os = Files.newOutputStream(liba);
-        JarOutputStream jos = new JarOutputStream(os)) {
-      {
-        jos.putNextEntry(new JarEntry("a/A.class"));
-        ClassWriter cw = new ClassWriter(0);
-        cw.visit(52, Opcodes.ACC_SUPER | Opcodes.ACC_PUBLIC, "a/A", null, "java/lang/Object", null);
-        cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
-        cw.visitInnerClass("a/A$1", "a/A", null, Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC);
-        cw.visitInnerClass("a/A$I", "a/A", "I", Opcodes.ACC_STATIC);
-        jos.write(cw.toByteArray());
-      }
-      {
-        jos.putNextEntry(new JarEntry("a/A$1.class"));
-        ClassWriter cw = new ClassWriter(0);
-        cw.visit(
-            52, Opcodes.ACC_SUPER | Opcodes.ACC_PUBLIC, "a/A$1", null, "java/lang/Object", null);
-        cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
-        cw.visitInnerClass("a/A$1", "a/A", "I", Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC);
-        jos.write(cw.toByteArray());
-      }
-      {
-        jos.putNextEntry(new JarEntry("a/A$I.class"));
-        ClassWriter cw = new ClassWriter(0);
-        cw.visit(
-            52, Opcodes.ACC_SUPER | Opcodes.ACC_PUBLIC, "a/A$I", null, "java/lang/Object", null);
-        cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
-        cw.visitInnerClass("a/A$I", "a/A", "I", Opcodes.ACC_STATIC);
-        jos.write(cw.toByteArray());
-      }
-    }
-    Path libb =
-        runTurbine(
-            new SourceBuilder()
-                .addSourceLines(
-                    "b/B.java", //
-                    "package b;",
-                    "public class B extends a.A {}")
-                .build(),
-            ImmutableList.of(liba));
-
-    // libb repackages A and any named member types
-    assertThat(readJar(libb).keySet())
-        .containsExactly(
-            "b/B.class", "META-INF/TRANSITIVE/a/A.class", "META-INF/TRANSITIVE/a/A$I.class");
-  }
-
-  @Test
-  public void childClass() throws Exception {
-    Path liba =
-        runTurbine(
-            new SourceBuilder()
-                .addSourceLines(
-                    "a/S.java", //
-                    "package a;",
-                    "public class S {}")
-                .addSourceLines(
-                    "a/A.java", //
-                    "package a;",
-                    "public class A {",
-                    "  public class I extends S {}",
-                    "}")
-                .build(),
-            ImmutableList.of());
-
-    Path libb =
-        runTurbine(
-            new SourceBuilder()
-                .addSourceLines(
-                    "b/B.java", //
-                    "package b;",
-                    "public class B extends a.A {",
-                    "  class I extends a.A.I {",
-                    "  }",
-                    "}")
-                .build(),
-            ImmutableList.of(liba));
-
-    assertThat(readJar(libb).keySet())
-        .containsExactly(
-            "b/B.class",
-            "b/B$I.class",
-            "META-INF/TRANSITIVE/a/A.class",
-            "META-INF/TRANSITIVE/a/A$I.class",
-            "META-INF/TRANSITIVE/a/S.class");
-  }
-}
diff --git a/javatests/com/google/turbine/deps/TransitiveTest.java b/javatests/com/google/turbine/deps/TransitiveTest.java
index 2c9f807..f08e899 100644
--- a/javatests/com/google/turbine/deps/TransitiveTest.java
+++ b/javatests/com/google/turbine/deps/TransitiveTest.java
@@ -17,20 +17,281 @@
 package com.google.turbine.deps;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.collect.ImmutableMap.toImmutableMap;
+import static com.google.common.collect.Iterables.getOnlyElement;
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+import com.google.common.io.ByteStreams;
+import com.google.protobuf.ExtensionRegistry;
+import com.google.turbine.bytecode.ClassFile;
+import com.google.turbine.bytecode.ClassFile.InnerClass;
+import com.google.turbine.bytecode.ClassReader;
 import com.google.turbine.main.Main;
+import com.google.turbine.proto.DepsProto;
+import com.google.turbine.proto.DepsProto.Dependency.Kind;
+import java.io.BufferedInputStream;
 import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import java.util.jar.JarOutputStream;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.Opcodes;
 
 @RunWith(JUnit4.class)
-public class TransitiveTest extends AbstractTransitiveTest {
+public class TransitiveTest {
 
-  @Override
-  protected Path runTurbine(ImmutableList<Path> sources, ImmutableList<Path> classpath)
+  @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+  class SourceBuilder {
+    private final Path lib;
+    private final ImmutableList.Builder<Path> sources = ImmutableList.builder();
+
+    SourceBuilder() throws IOException {
+      lib = temporaryFolder.newFolder().toPath();
+    }
+
+    SourceBuilder addSourceLines(String name, String... lines) throws IOException {
+      Path path = lib.resolve(name);
+      Files.createDirectories(path.getParent());
+      Files.write(path, Arrays.asList(lines), UTF_8);
+      sources.add(path);
+      return this;
+    }
+
+    ImmutableList<Path> build() {
+      return sources.build();
+    }
+  }
+
+  private static Map<String, byte[]> readJar(Path libb) throws IOException {
+    Map<String, byte[]> jarEntries = new LinkedHashMap<>();
+    try (JarFile jf = new JarFile(libb.toFile())) {
+      Enumeration<JarEntry> entries = jf.entries();
+      while (entries.hasMoreElements()) {
+        JarEntry je = entries.nextElement();
+        jarEntries.put(je.getName(), ByteStreams.toByteArray(jf.getInputStream(je)));
+      }
+    }
+    return jarEntries;
+  }
+
+  @Test
+  public void transitive() throws Exception {
+    Path liba =
+        runTurbine(
+            new SourceBuilder()
+                .addSourceLines(
+                    "a/A.java",
+                    "package a;",
+                    "import java.util.Map;",
+                    "public class A {",
+                    "  public @interface Anno {",
+                    "    int x() default 42;",
+                    "  }",
+                    "  public static class Inner {}",
+                    "  public static final int CONST = 42;",
+                    "  public int mutable = 42;",
+                    "  public Map.Entry<String, String> f(Map<String, String> m) {",
+                    "    return m.entrySet().iterator().next();",
+                    "  }",
+                    "}")
+                .build(),
+            ImmutableList.of());
+
+    Path libb =
+        runTurbine(
+            new SourceBuilder()
+                .addSourceLines("b/B.java", "package b;", "public class B extends a.A {}")
+                .build(),
+            ImmutableList.of(liba));
+
+    // libb repackages A, and any member types
+    assertThat(readJar(libb).keySet())
+        .containsExactly(
+            "b/B.class",
+            "META-INF/TRANSITIVE/a/A.class",
+            "META-INF/TRANSITIVE/a/A$Anno.class",
+            "META-INF/TRANSITIVE/a/A$Inner.class");
+
+    ClassFile a = ClassReader.read(null, readJar(libb).get("META-INF/TRANSITIVE/a/A.class"));
+    // methods and non-constant fields are removed
+    assertThat(getOnlyElement(a.fields()).name()).isEqualTo("CONST");
+    assertThat(a.methods()).isEmpty();
+    assertThat(Iterables.transform(a.innerClasses(), InnerClass::innerClass))
+        .containsExactly("a/A$Anno", "a/A$Inner");
+
+    // annotation interface methods are preserved
+    assertThat(
+            ClassReader.read(null, readJar(libb).get("META-INF/TRANSITIVE/a/A$Anno.class"))
+                .methods())
+        .hasSize(1);
+
+    // When a.A is repackaged as a transitive class in libb, its 'transitive jar' attribute
+    // should record the path to the original liba jar.
+    assertThat(a.transitiveJar()).isEqualTo(liba.toString());
+    // The transitive jar attribute is only set for transitive classes, not e.g. b.B in libb:
+    ClassFile b = ClassReader.read(null, readJar(libb).get("b/B.class"));
+    assertThat(b.transitiveJar()).isNull();
+
+    // A class that references members of the transitive supertype A by simple name
+    // compiles cleanly against the repackaged version of A.
+    // Explicitly use turbine; javac-turbine doesn't support direct-classpath compilations.
+
+    Path libc = temporaryFolder.newFolder().toPath().resolve("out.jar");
+    Path libcDeps = temporaryFolder.newFolder().toPath().resolve("out.jdeps");
+    ImmutableList<String> sources =
+        new SourceBuilder()
+                .addSourceLines(
+                    "c/C.java",
+                    "package c;",
+                    "public class C extends b.B {",
+                    "  @Anno(x = 2) static final Inner i; // a.A$Inner ",
+                    "  static final int X = CONST; // a.A#CONST",
+                    "}")
+                .build()
+                .stream()
+                .map(Path::toString)
+                .collect(toImmutableList());
+    Main.compile(
+        optionsWithBootclasspath()
+            .setSources(sources)
+            .setClassPath(
+                ImmutableList.of(libb).stream().map(Path::toString).collect(toImmutableList()))
+            .setOutput(libc.toString())
+            .setOutputDeps(libcDeps.toString())
+            .build());
+
+    assertThat(readJar(libc).keySet())
+        .containsExactly(
+            "c/C.class",
+            "META-INF/TRANSITIVE/b/B.class",
+            "META-INF/TRANSITIVE/a/A.class",
+            "META-INF/TRANSITIVE/a/A$Anno.class",
+            "META-INF/TRANSITIVE/a/A$Inner.class");
+
+    // liba is recorded as an explicit dep, even thought it's only present as a transitive class
+    // repackaged in lib
+    assertThat(readDeps(libcDeps))
+        .containsExactly(liba.toString(), Kind.EXPLICIT, libb.toString(), Kind.EXPLICIT);
+  }
+
+  private static ImmutableMap<String, Kind> readDeps(Path libcDeps) throws IOException {
+    DepsProto.Dependencies.Builder deps = DepsProto.Dependencies.newBuilder();
+    try (InputStream is = new BufferedInputStream(Files.newInputStream(libcDeps))) {
+      deps.mergeFrom(is, ExtensionRegistry.getEmptyRegistry());
+    }
+    return deps.getDependencyList().stream()
+        .collect(toImmutableMap(d -> d.getPath(), d -> d.getKind()));
+  }
+
+  @Test
+  public void anonymous() throws Exception {
+    Path liba = temporaryFolder.newFolder().toPath().resolve("out.jar");
+    try (OutputStream os = Files.newOutputStream(liba);
+        JarOutputStream jos = new JarOutputStream(os)) {
+      {
+        jos.putNextEntry(new JarEntry("a/A.class"));
+        ClassWriter cw = new ClassWriter(0);
+        cw.visit(52, Opcodes.ACC_SUPER | Opcodes.ACC_PUBLIC, "a/A", null, "java/lang/Object", null);
+        cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        cw.visitInnerClass("a/A$1", "a/A", null, Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC);
+        cw.visitInnerClass("a/A$I", "a/A", "I", Opcodes.ACC_STATIC);
+        jos.write(cw.toByteArray());
+      }
+      {
+        jos.putNextEntry(new JarEntry("a/A$1.class"));
+        ClassWriter cw = new ClassWriter(0);
+        cw.visit(
+            52, Opcodes.ACC_SUPER | Opcodes.ACC_PUBLIC, "a/A$1", null, "java/lang/Object", null);
+        cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        cw.visitInnerClass("a/A$1", "a/A", "I", Opcodes.ACC_STATIC | Opcodes.ACC_SYNTHETIC);
+        jos.write(cw.toByteArray());
+      }
+      {
+        jos.putNextEntry(new JarEntry("a/A$I.class"));
+        ClassWriter cw = new ClassWriter(0);
+        cw.visit(
+            52, Opcodes.ACC_SUPER | Opcodes.ACC_PUBLIC, "a/A$I", null, "java/lang/Object", null);
+        cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>", "()V", null, null);
+        cw.visitInnerClass("a/A$I", "a/A", "I", Opcodes.ACC_STATIC);
+        jos.write(cw.toByteArray());
+      }
+    }
+    Path libb =
+        runTurbine(
+            new SourceBuilder()
+                .addSourceLines(
+                    "b/B.java", //
+                    "package b;",
+                    "public class B extends a.A {}")
+                .build(),
+            ImmutableList.of(liba));
+
+    // libb repackages A and any named member types
+    assertThat(readJar(libb).keySet())
+        .containsExactly(
+            "b/B.class", "META-INF/TRANSITIVE/a/A.class", "META-INF/TRANSITIVE/a/A$I.class");
+  }
+
+  @Test
+  public void childClass() throws Exception {
+    Path liba =
+        runTurbine(
+            new SourceBuilder()
+                .addSourceLines(
+                    "a/S.java", //
+                    "package a;",
+                    "public class S {}")
+                .addSourceLines(
+                    "a/A.java", //
+                    "package a;",
+                    "public class A {",
+                    "  public class I extends S {}",
+                    "}")
+                .build(),
+            ImmutableList.of());
+
+    Path libb =
+        runTurbine(
+            new SourceBuilder()
+                .addSourceLines(
+                    "b/B.java", //
+                    "package b;",
+                    "public class B extends a.A {",
+                    "  class I extends a.A.I {",
+                    "  }",
+                    "}")
+                .build(),
+            ImmutableList.of(liba));
+
+    assertThat(readJar(libb).keySet())
+        .containsExactly(
+            "b/B.class",
+            "b/B$I.class",
+            "META-INF/TRANSITIVE/a/A.class",
+            "META-INF/TRANSITIVE/a/A$I.class",
+            "META-INF/TRANSITIVE/a/S.class");
+  }
+
+  private Path runTurbine(ImmutableList<Path> sources, ImmutableList<Path> classpath)
       throws IOException {
     Path out = temporaryFolder.newFolder().toPath().resolve("out.jar");
     Main.compile(
diff --git a/javatests/com/google/turbine/lower/IntegrationTestSupport.java b/javatests/com/google/turbine/lower/IntegrationTestSupport.java
index a03473d..744f341 100644
--- a/javatests/com/google/turbine/lower/IntegrationTestSupport.java
+++ b/javatests/com/google/turbine/lower/IntegrationTestSupport.java
@@ -17,8 +17,10 @@
 package com.google.turbine.lower;
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
+import static com.google.common.io.MoreFiles.getFileExtension;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 import static java.util.stream.Collectors.toCollection;
 import static java.util.stream.Collectors.toList;
@@ -82,7 +84,7 @@
 import org.objectweb.asm.tree.TypeAnnotationNode;
 
 /** Support for bytecode diffing-integration tests. */
-public class IntegrationTestSupport {
+public final class IntegrationTestSupport {
 
   /**
    * Normalizes order of members, attributes, and constant pool entries, to allow diffing bytecode.
@@ -410,20 +412,21 @@
     final Set<String> classes1 = classes;
     new SignatureReader(signature)
         .accept(
-            new SignatureVisitor(Opcodes.ASM7) {
+            new SignatureVisitor(Opcodes.ASM9) {
               private final Set<String> classes = classes1;
               // class signatures may contain type arguments that contain class signatures
               Deque<List<String>> pieces = new ArrayDeque<>();
 
               @Override
               public void visitInnerClassType(String name) {
-                pieces.peek().add(name);
+                pieces.element().add(name);
               }
 
               @Override
               public void visitClassType(String name) {
-                pieces.push(new ArrayList<>());
-                pieces.peek().add(name);
+                List<String> classType = new ArrayList<>();
+                classType.add(name);
+                pieces.push(classType);
               }
 
               @Override
@@ -510,7 +513,7 @@
           @Override
           public FileVisitResult visitFile(Path path, BasicFileAttributes attrs)
               throws IOException {
-            if (path.getFileName().toString().endsWith(".class")) {
+            if (getFileExtension(path).equals("class")) {
               classes.add(path);
             }
             return FileVisitResult.CONTINUE;
@@ -551,7 +554,9 @@
     fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, ImmutableList.of(out));
     fileManager.setLocationFromPaths(StandardLocation.CLASS_PATH, classpath);
     fileManager.setLocationFromPaths(StandardLocation.locationFor("MODULE_PATH"), classpath);
-    if (inputs.stream().filter(i -> i.getFileName().toString().equals("module-info.java")).count()
+    if (inputs.stream()
+            .filter(i -> requireNonNull(i.getFileName()).toString().equals("module-info.java"))
+            .count()
         > 1) {
       // multi-module mode
       fileManager.setLocationFromPaths(
@@ -578,7 +583,7 @@
         na = na.substring(1);
       }
       sb.append(String.format("=== %s ===\n", na));
-      sb.append(AsmUtils.textify(compiled.get(key)));
+      sb.append(AsmUtils.textify(compiled.get(key), /* skipDebug= */ true));
     }
     return sb.toString();
   }
@@ -634,4 +639,6 @@
       return new TestInput(sources, classes);
     }
   }
+
+  private IntegrationTestSupport() {}
 }
diff --git a/javatests/com/google/turbine/lower/LowerIntegrationTest.java b/javatests/com/google/turbine/lower/LowerIntegrationTest.java
index 85c3450..ab4e0ee 100644
--- a/javatests/com/google/turbine/lower/LowerIntegrationTest.java
+++ b/javatests/com/google/turbine/lower/LowerIntegrationTest.java
@@ -17,12 +17,11 @@
 package com.google.turbine.lower;
 
 import static com.google.common.truth.Truth.assertThat;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.turbine.testing.TestResources.getResource;
 import static java.util.stream.Collectors.toList;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Lists;
-import com.google.common.io.ByteStreams;
 import java.io.IOError;
 import java.io.IOException;
 import java.nio.file.Files;
@@ -45,143 +44,30 @@
   @Parameters(name = "{index}: {0}")
   public static Iterable<Object[]> parameters() {
     String[] testCases = {
+      // keep-sorted start
+      "B33513475.test",
+      "B33513475b.test",
+      "B33513475c.test",
+      "B70953542.test",
+      "B8056066.test",
+      "B8056066b.test",
+      "B8075274.test",
+      "B8148131.test",
       "abstractenum.test",
       "access1.test",
-      "anonymous.test",
-      "asset.test",
-      "outerparam.test",
-      "basic_field.test",
-      "basic_nested.test",
-      "bcp.test",
-      "builder.test",
-      "byte.test",
-      "byte2.test",
-      "circ_cvar.test",
-      "clash.test",
-      "ctorvis.test",
-      "cvar_qualified.test",
-      "cycle.test",
-      "default_fbound.test",
-      "default_rawfbound.test",
-      "default_simple.test",
-      "enum1.test",
-      "enumctor.test",
-      "enumctor2.test",
-      "enumimpl.test",
-      "enumingeneric.test",
-      "enuminner.test",
-      "enumint.test",
-      "enumint2.test",
-      "enumint3.test",
-      "enumint_byte.test",
-      "enumint_objectmethod.test",
-      "enumint_objectmethod2.test",
-      "enumint_objectmethod_raw.test",
-      "enuminthacks.test",
-      "enumstat.test",
-      "erasurebound.test",
-      "existingctor.test",
-      "extend_inner.test",
-      "extends_bound.test",
-      "extends_otherbound.test",
-      "extendsandimplements.test",
-      "extrainnerclass.test",
-      "fbound.test",
-      "firstcomparator.test",
-      "fuse.test",
-      "genericarrayfield.test",
-      "genericexn.test",
-      "genericexn2.test",
-      "genericret.test",
-      "hierarchy.test",
-      "ibound.test",
-      "icu.test",
-      "icu2.test",
-      "importinner.test",
-      "innerctor.test",
-      "innerenum.test",
-      "innerint.test",
-      "innerstaticgeneric.test",
-      "interfacemem.test",
-      "interfaces.test",
-      "lexical.test",
-      "lexical2.test",
-      "lexical4.test",
-      "list.test",
-      "loopthroughb.test",
-      "mapentry.test",
-      "member.test",
-      "mods.test",
-      "morefields.test",
-      "moremethods.test",
-      "multifield.test",
-      "nested.test",
-      "nested2.test",
-      "one.test",
-      "outer.test",
-      "packageprivateprotectedinner.test",
-      "param_bound.test",
-      "privateinner.test",
-      "proto.test",
-      "proto2.test",
-      "qual.test",
-      "raw.test",
-      "raw2.test",
-      "rawfbound.test",
-      "rek.test",
-      "samepkg.test",
-      "self.test",
-      "semi.test",
-      "simple.test",
-      "simplemethod.test",
-      "string.test",
-      "superabstract.test",
-      "supplierfunction.test",
-      "tbound.test",
-      "typaram.test",
-      "tyvarfield.test",
-      "useextend.test",
-      "vanillaexception.test",
-      "varargs.test",
-      "wild.test",
-      "bytenoncanon.test",
-      "canon.test",
-      "genericnoncanon.test",
-      "genericnoncanon1.test",
-      "genericnoncanon10.test",
-      "genericnoncanon2.test",
-      "genericnoncanon3.test",
-      "genericnoncanon4.test",
-      "genericnoncanon5.test",
-      "genericnoncanon6.test",
-      "genericnoncanon8.test",
-      "genericnoncanon9.test",
-      "genericnoncanon_byte.test",
-      "genericnoncanon_method3.test",
-      "noncanon.test",
-      "rawcanon.test",
-      "wildboundcanon.test",
-      "wildcanon.test",
+      "anno_const_coerce.test",
+      "anno_const_scope.test",
+      "anno_nested.test",
+      "anno_repeated.test",
+      "anno_self_const.test",
+      "anno_void.test",
       "annoconstvis.test",
-      "const_byte.test",
-      "const_char.test",
-      "const_field.test",
-      "const_types.test",
-      "const_underscore.test",
-      "constlevel.test",
-      "constpack.test",
-      "importconst.test",
-      "const.test",
-      "const_all.test",
-      "const_arith.test",
-      "const_conditional.test",
-      "const_moreexpr.test",
-      "const_multi.test",
-      "field_anno.test",
       "annotation_bool_default.test",
       "annotation_class_default.test",
+      "annotation_clinit.test",
       "annotation_declaration.test",
       "annotation_enum_default.test",
+      "annotation_scope.test",
       "annotations_default.test",
       "annouse.test",
       "annouse10.test",
@@ -201,116 +87,233 @@
       "annouse8.test",
       "annouse9.test",
       "annovis.test",
-      "complex_param_anno.test",
-      "enummemberanno.test",
-      "innerannodecl.test",
-      "source_anno_retention.test",
-      "anno_nested.test",
-      "nested_member_import.test",
-      "nested_member_import_noncanon.test",
-      "unary.test",
-      "hex_int.test",
-      "const_conv.test",
+      "anonymous.test",
+      "array_class_literal.test",
+      "ascii_sub.test",
+      "asset.test",
+      "basic_field.test",
+      "basic_nested.test",
+      "bcp.test",
       "bmethod.test",
-      "prim_class.test",
-      "wild2.test",
-      "wild3.test",
-      "const_hiding.test",
-      "interface_field.test",
-      "concat.test",
-      "static_type_import.test",
-      "non_const.test",
       "bounds.test",
-      "cast_tail.test",
-      "marker.test",
-      "interface_method.test",
-      "raw_canon.test",
-      "float_exponent.test",
       "boxed_const.test",
-      "package_info.test",
-      "import_wild_order.test",
+      "builder.test",
+      "byte.test",
+      "byte2.test",
+      "bytecode_boolean_const.test",
+      "bytenoncanon.test",
+      "c_array.test",
+      "canon.test",
+      "canon_class_header.test",
       "canon_recursive.test",
+      "cast_tail.test",
+      "circ_cvar.test",
+      "clash.test",
+      "complex_param_anno.test",
+      "concat.test",
+      "const.test",
+      "const_all.test",
+      "const_arith.test",
+      "const_boxed.test",
+      "const_byte.test",
+      "const_char.test",
+      "const_conditional.test",
+      "const_conv.test",
+      "const_field.test",
+      "const_hiding.test",
+      "const_moreexpr.test",
+      "const_multi.test",
+      "const_nonfinal.test",
+      "const_octal_underscore.test",
+      "const_types.test",
+      "const_underscore.test",
+      "constlevel.test",
+      "constpack.test",
+      "ctor_anno.test",
+      "ctorvis.test",
+      "cvar_qualified.test",
+      "cycle.test",
+      "default_fbound.test",
+      "default_rawfbound.test",
+      "default_simple.test",
+      "deficient_types_classfile.test",
+      "dollar.test",
+      "enum1.test",
+      "enum_abstract.test",
+      "enum_final.test",
+      "enumctor.test",
+      "enumctor2.test",
+      "enumimpl.test",
+      "enumingeneric.test",
+      "enuminner.test",
+      "enumint.test",
+      "enumint2.test",
+      "enumint3.test",
+      "enumint_byte.test",
+      "enumint_objectmethod.test",
+      "enumint_objectmethod2.test",
+      "enumint_objectmethod_raw.test",
+      "enuminthacks.test",
+      "enummemberanno.test",
+      "enumstat.test",
+      "erasurebound.test",
+      "existingctor.test",
+      "extend_inner.test",
+      "extends_bound.test",
+      "extends_otherbound.test",
+      "extendsandimplements.test",
+      "extrainnerclass.test",
+      "fbound.test",
+      "field_anno.test",
+      "firstcomparator.test",
+      "float_exponent.test",
+      "fuse.test",
+      "genericarrayfield.test",
+      "genericexn.test",
+      "genericexn2.test",
+      "genericnoncanon.test",
+      "genericnoncanon1.test",
+      "genericnoncanon10.test",
+      "genericnoncanon2.test",
+      "genericnoncanon3.test",
+      "genericnoncanon4.test",
+      "genericnoncanon5.test",
+      "genericnoncanon6.test",
+      "genericnoncanon8.test",
+      "genericnoncanon9.test",
+      "genericnoncanon_byte.test",
+      "genericnoncanon_method3.test",
+      "genericret.test",
+      "hex_int.test",
+      "hierarchy.test",
+      "ibound.test",
+      "icu.test",
+      "icu2.test",
+      "import_wild_order.test",
+      "importconst.test",
+      "importinner.test",
+      "inner_static.test",
+      "innerannodecl.test",
+      "innerclassanno.test",
+      "innerctor.test",
+      "innerenum.test",
+      "innerint.test",
+      "innerstaticgeneric.test",
+      "interface_field.test",
+      "interface_member_public.test",
+      "interface_method.test",
+      "interfacemem.test",
+      "interfaces.test",
       // TODO(cushon): crashes ASM, see:
       // https://gitlab.ow2.org/asm/asm/issues/317776
       // "canon_array.test",
       "java_lang_object.test",
-      "visible_package.test",
-      "visible_private.test",
-      "visible_same_package.test",
-      "private_member.test",
-      "visible_nested.test",
-      "visible_qualified.test",
-      "ascii_sub.test",
-      "bytecode_boolean_const.test",
-      "tyvar_bound.test",
-      "type_anno_hello.test",
-      "type_anno_array_dims.test",
-      "nonconst_unary_expression.test",
-      "type_anno_ambiguous.test",
-      "type_anno_ambiguous_param.test",
-      "unicode.test",
-      "annotation_scope.test",
-      "visible_package_private_toplevel.test",
-      "receiver_param.test",
-      "static_member_type_import.test",
-      "type_anno_qual.test",
-      "array_class_literal.test",
-      "underscore_literal.test",
-      "c_array.test",
-      "type_anno_retention.test",
-      "member_import_clash.test",
-      "anno_repeated.test",
-      "long_expression.test",
-      "const_nonfinal.test",
-      "enum_abstract.test",
-      "deficient_types_classfile.test",
-      "ctor_anno.test",
-      "anno_const_coerce.test",
-      "const_octal_underscore.test",
-      "const_boxed.test",
-      "interface_member_public.test",
       "javadoc_deprecated.test",
-      "strictfp.test",
-      "type_anno_raw.test",
-      "inner_static.test",
-      "innerclassanno.test",
-      "type_anno_parameter_index.test",
-      "anno_const_scope.test",
-      "type_anno_ambiguous_qualified.test",
-      "type_anno_array_bound.test",
-      "type_anno_return.test",
-      "type_anno_order.test",
-      "canon_class_header.test",
-      "type_anno_receiver.test",
-      "enum_final.test",
-      "dollar.test",
-      "typaram_lookup.test",
-      "typaram_lookup_enclosing.test",
-      "B33513475.test",
-      "B33513475b.test",
-      "B33513475c.test",
-      "noncanon_static_wild.test",
-      "B8075274.test",
-      "B8148131.test",
-      "B8056066.test",
-      "B8056066b.test",
-      "source_bootclasspath_order.test",
-      "anno_self_const.test",
-      "type_anno_cstyle_array_dims.test",
-      "packagedecl.test",
-      "static_member_type_import_recursive.test",
-      "B70953542.test",
+      "lexical.test",
+      "lexical2.test",
+      "lexical4.test",
+      "list.test",
+      "local.test",
+      "long_expression.test",
+      "loopthroughb.test",
+      "mapentry.test",
+      "marker.test",
+      "member.test",
+      "member_import_clash.test",
       // TODO(cushon): support for source level 9 in integration tests
       // "B74332665.test",
       "memberimport.test",
-      "type_anno_c_array.test",
+      "mods.test",
+      "morefields.test",
+      "moremethods.test",
+      "multifield.test",
+      "nested.test",
+      "nested2.test",
+      "nested_member_import.test",
+      "nested_member_import_noncanon.test",
+      "non_const.test",
+      "noncanon.test",
+      "noncanon_static_wild.test",
+      "nonconst_unary_expression.test",
+      "one.test",
+      "outer.test",
+      "outerparam.test",
+      "package_info.test",
+      "packagedecl.test",
+      "packageprivateprotectedinner.test",
+      "param_bound.test",
+      "prim_class.test",
+      "private_member.test",
+      "privateinner.test",
+      "proto.test",
+      "proto2.test",
+      "qual.test",
+      "raw.test",
+      "raw2.test",
+      "raw_canon.test",
+      "rawcanon.test",
+      "rawfbound.test",
+      "receiver_param.test",
+      "rek.test",
+      "samepkg.test",
+      "self.test",
+      "semi.test",
       // https://bugs.openjdk.java.net/browse/JDK-8054064 ?
       "shadow_inherited.test",
+      "simple.test",
+      "simplemethod.test",
+      "source_anno_retention.test",
+      "source_bootclasspath_order.test",
       "static_final_boxed.test",
-      "anno_void.test",
-      "tyanno_varargs.test",
+      "static_member_type_import.test",
+      "static_member_type_import_recursive.test",
+      "static_type_import.test",
+      "strictfp.test",
+      "string.test",
+      "superabstract.test",
+      "supplierfunction.test",
+      "tbound.test",
       "tyanno_inner.test",
-      "local.test",
+      "tyanno_varargs.test",
+      "typaram.test",
+      "typaram_lookup.test",
+      "typaram_lookup_enclosing.test",
+      "type_anno_ambiguous.test",
+      "type_anno_ambiguous_param.test",
+      "type_anno_ambiguous_qualified.test",
+      "type_anno_array_bound.test",
+      "type_anno_array_dims.test",
+      "type_anno_c_array.test",
+      "type_anno_cstyle_array_dims.test",
+      "type_anno_hello.test",
+      "type_anno_order.test",
+      "type_anno_parameter_index.test",
+      "type_anno_qual.test",
+      "type_anno_raw.test",
+      "type_anno_receiver.test",
+      "type_anno_retention.test",
+      "type_anno_return.test",
+      "tyvar_bound.test",
+      "tyvarfield.test",
+      "unary.test",
+      "underscore_literal.test",
+      "unicode.test",
+      "unicode_pkg.test",
+      "useextend.test",
+      "vanillaexception.test",
+      "varargs.test",
+      "visible_nested.test",
+      "visible_package.test",
+      "visible_package_private_toplevel.test",
+      "visible_private.test",
+      "visible_qualified.test",
+      "visible_same_package.test",
+      "wild.test",
+      "wild2.test",
+      "wild3.test",
+      "wildboundcanon.test",
+      "wildcanon.test",
+      // keep-sorted end
     };
     List<Object[]> tests =
         ImmutableList.copyOf(testCases).stream().map(x -> new Object[] {x}).collect(toList());
@@ -344,10 +347,7 @@
   public void test() throws Exception {
 
     IntegrationTestSupport.TestInput input =
-        IntegrationTestSupport.TestInput.parse(
-            new String(
-                ByteStreams.toByteArray(getClass().getResourceAsStream("testdata/" + test)),
-                UTF_8));
+        IntegrationTestSupport.TestInput.parse(getResource(getClass(), "testdata/" + test));
 
     ImmutableList<Path> classpathJar = ImmutableList.of();
     if (!input.classes.isEmpty()) {
diff --git a/javatests/com/google/turbine/lower/LowerTest.java b/javatests/com/google/turbine/lower/LowerTest.java
index 8151e81..d74e829 100644
--- a/javatests/com/google/turbine/lower/LowerTest.java
+++ b/javatests/com/google/turbine/lower/LowerTest.java
@@ -18,13 +18,13 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.TURBINE_BOOTCLASSPATH;
-import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
+import static com.google.turbine.testing.TestResources.getResource;
+import static java.util.Objects.requireNonNull;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
-import com.google.common.io.ByteStreams;
 import com.google.turbine.binder.Binder;
 import com.google.turbine.binder.Binder.BindingResult;
 import com.google.turbine.binder.ClassPathBinder;
@@ -233,18 +233,10 @@
                 TURBINE_BOOTCLASSPATH.env())
             .bytes();
 
-    assertThat(AsmUtils.textify(bytes.get("test/Test")))
-        .isEqualTo(
-            new String(
-                ByteStreams.toByteArray(
-                    LowerTest.class.getResourceAsStream("testdata/golden/outer.txt")),
-                UTF_8));
-    assertThat(AsmUtils.textify(bytes.get("test/Test$Inner")))
-        .isEqualTo(
-            new String(
-                ByteStreams.toByteArray(
-                    LowerTest.class.getResourceAsStream("testdata/golden/inner.txt")),
-                UTF_8));
+    assertThat(AsmUtils.textify(bytes.get("test/Test"), /* skipDebug= */ false))
+        .isEqualTo(getResource(LowerTest.class, "testdata/golden/outer.txt"));
+    assertThat(AsmUtils.textify(bytes.get("test/Test$Inner"), /* skipDebug= */ false))
+        .isEqualTo(getResource(LowerTest.class, "testdata/golden/inner.txt"));
   }
 
   @Test
@@ -268,7 +260,7 @@
     List<String> attributes = new ArrayList<>();
     new ClassReader(lowered.get("Test$Inner$InnerMost"))
         .accept(
-            new ClassVisitor(Opcodes.ASM7) {
+            new ClassVisitor(Opcodes.ASM9) {
               @Override
               public void visitInnerClass(
                   String name, String outerName, String innerName, int access) {
@@ -285,10 +277,7 @@
   public void wildArrayElement() throws Exception {
     IntegrationTestSupport.TestInput input =
         IntegrationTestSupport.TestInput.parse(
-            new String(
-                ByteStreams.toByteArray(
-                    getClass().getResourceAsStream("testdata/canon_array.test")),
-                UTF_8));
+            getResource(getClass(), "testdata/canon_array.test"));
 
     Map<String, byte[]> actual =
         IntegrationTestSupport.runTurbine(input.sources, ImmutableList.of());
@@ -346,11 +335,11 @@
     TypePath[] path = new TypePath[1];
     new ClassReader(lowered.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM7) {
+            new ClassVisitor(Opcodes.ASM9) {
               @Override
               public FieldVisitor visitField(
                   int access, String name, String desc, String signature, Object value) {
-                return new FieldVisitor(Opcodes.ASM7) {
+                return new FieldVisitor(Opcodes.ASM9) {
                   @Override
                   public AnnotationVisitor visitTypeAnnotation(
                       int typeRef, TypePath typePath, String desc, boolean visible) {
@@ -397,7 +386,7 @@
     Map<String, Object> values = new LinkedHashMap<>();
     new ClassReader(actual.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM7) {
+            new ClassVisitor(Opcodes.ASM9) {
               @Override
               public FieldVisitor visitField(
                   int access, String name, String desc, String signature, Object value) {
@@ -424,7 +413,7 @@
     int[] acc = {0};
     new ClassReader(lowered.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM7) {
+            new ClassVisitor(Opcodes.ASM9) {
               @Override
               public void visit(
                   int version,
@@ -522,16 +511,11 @@
     Path libJar = temporaryFolder.newFile("lib.jar").toPath();
     try (OutputStream os = Files.newOutputStream(libJar);
         JarOutputStream jos = new JarOutputStream(os)) {
-      jos.putNextEntry(new JarEntry("A$M.class"));
-      jos.write(lib.get("A$M"));
-      jos.putNextEntry(new JarEntry("A$M$I.class"));
-      jos.write(lib.get("A$M$I"));
-      jos.putNextEntry(new JarEntry("B.class"));
-      jos.write(lib.get("B"));
-      jos.putNextEntry(new JarEntry("B$BM.class"));
-      jos.write(lib.get("B$BM"));
-      jos.putNextEntry(new JarEntry("B$BM$BI.class"));
-      jos.write(lib.get("B$BM$BI"));
+      write(jos, lib, "A$M");
+      write(jos, lib, "A$M$I");
+      write(jos, lib, "B");
+      write(jos, lib, "B$BM");
+      write(jos, lib, "B$BM$BI");
     }
 
     ImmutableMap<String, String> sources =
@@ -544,14 +528,13 @@
                     "}"))
             .build();
 
-    try {
-      IntegrationTestSupport.runTurbine(sources, ImmutableList.of(libJar));
-      fail();
-    } catch (TurbineError error) {
-      assertThat(error)
-          .hasMessageThat()
-          .contains("Test.java: error: could not locate class file for A");
-    }
+    TurbineError error =
+        assertThrows(
+            TurbineError.class,
+            () -> IntegrationTestSupport.runTurbine(sources, ImmutableList.of(libJar)));
+    assertThat(error)
+        .hasMessageThat()
+        .contains("Test.java: error: could not locate class file for A");
   }
 
   @Test
@@ -579,16 +562,11 @@
     Path libJar = temporaryFolder.newFile("lib.jar").toPath();
     try (OutputStream os = Files.newOutputStream(libJar);
         JarOutputStream jos = new JarOutputStream(os)) {
-      jos.putNextEntry(new JarEntry("A$M.class"));
-      jos.write(lib.get("A$M"));
-      jos.putNextEntry(new JarEntry("A$M$I.class"));
-      jos.write(lib.get("A$M$I"));
-      jos.putNextEntry(new JarEntry("B.class"));
-      jos.write(lib.get("B"));
-      jos.putNextEntry(new JarEntry("B$BM.class"));
-      jos.write(lib.get("B$BM"));
-      jos.putNextEntry(new JarEntry("B$BM$BI.class"));
-      jos.write(lib.get("B$BM$BI"));
+      write(jos, lib, "A$M");
+      write(jos, lib, "A$M$I");
+      write(jos, lib, "B");
+      write(jos, lib, "B$BM");
+      write(jos, lib, "B$BM$BI");
     }
 
     ImmutableMap<String, String> sources =
@@ -603,18 +581,15 @@
                     "}"))
             .build();
 
-    try {
-      IntegrationTestSupport.runTurbine(sources, ImmutableList.of(libJar));
-      fail();
-    } catch (TurbineError error) {
-      assertThat(error)
-          .hasMessageThat()
-          .contains(
-              lines(
-                  "Test.java:3: error: could not locate class file for A",
-                  "     I i;",
-                  "       ^"));
-    }
+    TurbineError error =
+        assertThrows(
+            TurbineError.class,
+            () -> IntegrationTestSupport.runTurbine(sources, ImmutableList.of(libJar)));
+    assertThat(error)
+        .hasMessageThat()
+        .contains(
+            lines(
+                "Test.java:3: error: could not locate class file for A", "     I i;", "       ^"));
   }
 
   // If an element incorrectly has multiple visibility modifiers, pick one, and rely on javac to
@@ -629,7 +604,7 @@
     int[] testAccess = {0};
     new ClassReader(lowered.get("Test"))
         .accept(
-            new ClassVisitor(Opcodes.ASM7) {
+            new ClassVisitor(Opcodes.ASM9) {
               @Override
               public void visit(
                   int version,
@@ -649,4 +624,9 @@
   static String lines(String... lines) {
     return Joiner.on(System.lineSeparator()).join(lines);
   }
+
+  static void write(JarOutputStream jos, Map<String, byte[]> lib, String name) throws IOException {
+    jos.putNextEntry(new JarEntry(name + ".class"));
+    jos.write(requireNonNull(lib.get(name)));
+  }
 }
diff --git a/javatests/com/google/turbine/lower/ModuleIntegrationTest.java b/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
index 03c6fb7..f2c0bbf 100644
--- a/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
+++ b/javatests/com/google/turbine/lower/ModuleIntegrationTest.java
@@ -18,12 +18,11 @@
 
 import static com.google.common.base.StandardSystemProperty.JAVA_CLASS_VERSION;
 import static com.google.common.collect.ImmutableMap.toImmutableMap;
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.turbine.testing.TestResources.getResource;
 import static java.util.stream.Collectors.toList;
 import static org.junit.Assert.assertEquals;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.io.ByteStreams;
 import com.google.turbine.binder.CtSymClassBinder;
 import com.google.turbine.binder.JimageClassBinder;
 import java.nio.file.Files;
@@ -68,10 +67,7 @@
     }
 
     IntegrationTestSupport.TestInput input =
-        IntegrationTestSupport.TestInput.parse(
-            new String(
-                ByteStreams.toByteArray(getClass().getResourceAsStream("moduletestdata/" + test)),
-                UTF_8));
+        IntegrationTestSupport.TestInput.parse(getResource(getClass(), "moduletestdata/" + test));
 
     ImmutableList<Path> classpathJar = ImmutableList.of();
     if (!input.classes.isEmpty()) {
diff --git a/javatests/com/google/turbine/lower/testdata/annotation_clinit.test b/javatests/com/google/turbine/lower/testdata/annotation_clinit.test
new file mode 100644
index 0000000..7419ed6
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/annotation_clinit.test
@@ -0,0 +1,18 @@
+%%% pkg/Anno.java %%%
+package pkg;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
+@Retention(RetentionPolicy.RUNTIME)
+public @interface Anno {
+  String CONSTANT = Anno.class.toString();
+
+  String value() default "";
+}
+
+=== pkg/T.java ===
+package pkg;
+
+@Anno
+class T {}
diff --git a/javatests/com/google/turbine/lower/testdata/unicode_pkg.test b/javatests/com/google/turbine/lower/testdata/unicode_pkg.test
new file mode 100644
index 0000000..85d38d9
--- /dev/null
+++ b/javatests/com/google/turbine/lower/testdata/unicode_pkg.test
@@ -0,0 +1,4 @@
+=== Test.java ===
+package pkg𐀀.test;
+
+class Test {}
diff --git a/javatests/com/google/turbine/main/MainTest.java b/javatests/com/google/turbine/main/MainTest.java
index 5d47632..57940f3 100644
--- a/javatests/com/google/turbine/main/MainTest.java
+++ b/javatests/com/google/turbine/main/MainTest.java
@@ -23,7 +23,8 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
+import static java.util.Objects.requireNonNull;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -40,8 +41,11 @@
 import java.io.OutputStream;
 import java.io.UncheckedIOException;
 import java.io.Writer;
+import java.nio.file.FileVisitResult;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.nio.file.SimpleFileVisitor;
+import java.nio.file.attribute.BasicFileAttributes;
 import java.time.LocalDateTime;
 import java.time.ZoneId;
 import java.util.Enumeration;
@@ -88,16 +92,16 @@
     }
     Path output = temporaryFolder.newFile("output.jar").toPath();
 
-    try {
-      Main.compile(
-          optionsWithBootclasspath()
-              .setSourceJars(ImmutableList.of(sourcesa.toString(), sourcesb.toString()))
-              .setOutput(output.toString())
-              .build());
-      fail();
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().contains("error: duplicate declaration of Test");
-    }
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Main.compile(
+                    optionsWithBootclasspath()
+                        .setSourceJars(ImmutableList.of(sourcesa.toString(), sourcesb.toString()))
+                        .setOutput(output.toString())
+                        .build()));
+    assertThat(e).hasMessageThat().contains("error: duplicate declaration of Test");
   }
 
   @Test
@@ -204,8 +208,8 @@
         assertThat(entries.map(JarEntry::getName))
             .containsAtLeast("META-INF/", "META-INF/MANIFEST.MF");
       }
-      Manifest manifest = jarFile.getManifest();
-      Attributes attributes = manifest.getMainAttributes();
+      Manifest manifest = requireNonNull(jarFile.getManifest());
+      Attributes attributes = requireNonNull(manifest.getMainAttributes());
       ImmutableMap<String, ?> entries =
           attributes.entrySet().stream()
               .collect(toImmutableMap(e -> e.getKey().toString(), Map.Entry::getValue));
@@ -215,12 +219,15 @@
               "Manifest-Version", "1.0",
               "Target-Label", "//foo:foo",
               "Injecting-Rule-Kind", "foo_library");
-      assertThat(jarFile.getEntry(JarFile.MANIFEST_NAME).getLastModifiedTime().toInstant())
+      assertThat(
+              requireNonNull(jarFile.getEntry(JarFile.MANIFEST_NAME))
+                  .getLastModifiedTime()
+                  .toInstant())
           .isEqualTo(
               LocalDateTime.of(2010, 1, 1, 0, 0, 0).atZone(ZoneId.systemDefault()).toInstant());
     }
     try (JarFile jarFile = new JarFile(gensrcOutput.toFile())) {
-      Manifest manifest = jarFile.getManifest();
+      Manifest manifest = requireNonNull(jarFile.getManifest());
       Attributes attributes = manifest.getMainAttributes();
       ImmutableMap<String, ?> entries =
           attributes.entrySet().stream()
@@ -257,16 +264,16 @@
 
     Path output = temporaryFolder.newFile("output.jar").toPath();
 
-    try {
-      Main.compile(
-          TurbineOptions.builder()
-              .setSources(ImmutableList.of(src.toString()))
-              .setOutput(output.toString())
-              .build());
-      fail();
-    } catch (IllegalArgumentException expected) {
-      assertThat(expected).hasMessageThat().contains("java.lang");
-    }
+    IllegalArgumentException expected =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                Main.compile(
+                    TurbineOptions.builder()
+                        .setSources(ImmutableList.of(src.toString()))
+                        .setOutput(output.toString())
+                        .build()));
+    assertThat(expected).hasMessageThat().contains("java.lang");
   }
 
   @Test
@@ -274,14 +281,17 @@
     Path src = temporaryFolder.newFile("Test.java").toPath();
     MoreFiles.asCharSink(src, UTF_8).write("public class Test {}");
 
-    try {
-      Main.compile(optionsWithBootclasspath().setSources(ImmutableList.of(src.toString())).build());
-      fail();
-    } catch (UsageException expected) {
-      assertThat(expected)
-          .hasMessageThat()
-          .contains("at least one of --output, --gensrc_output, or --resource_output is required");
-    }
+    UsageException expected =
+        assertThrows(
+            UsageException.class,
+            () ->
+                Main.compile(
+                    optionsWithBootclasspath()
+                        .setSources(ImmutableList.of(src.toString()))
+                        .build()));
+    assertThat(expected)
+        .hasMessageThat()
+        .contains("at least one of --output, --gensrc_output, or --resource_output is required");
   }
 
   @Test
@@ -471,4 +481,60 @@
       assertThat(entries.map(JarEntry::getName)).containsExactly("g/Gen.class");
     }
   }
+
+  @Test
+  public void testGensrcDirectoryOutput() throws IOException {
+    Path src = temporaryFolder.newFile("Foo.java").toPath();
+    MoreFiles.asCharSink(src, UTF_8).write("package f; @Deprecated class Foo {}");
+
+    Path output = temporaryFolder.newFile("output.jar").toPath();
+    Path gensrc = temporaryFolder.newFolder("gensrcOutput").toPath();
+
+    Main.compile(
+        optionsWithBootclasspath()
+            .setSources(ImmutableList.of(src.toString()))
+            .setTargetLabel("//foo:foo")
+            .setInjectingRuleKind("foo_library")
+            .setOutput(output.toString())
+            .setGensrcOutput(gensrc.toString())
+            .setProcessors(ImmutableList.of(SourceGeneratingProcessor.class.getName()))
+            .build());
+
+    assertThat(listDirectoryContents(gensrc)).containsExactly(gensrc.resolve("g/Gen.java"));
+  }
+
+  @Test
+  public void testResourceDirectoryOutput() throws IOException {
+    Path src = temporaryFolder.newFile("Foo.java").toPath();
+    MoreFiles.asCharSink(src, UTF_8).write("package f; @Deprecated class Foo {}");
+
+    Path output = temporaryFolder.newFile("output.jar").toPath();
+    Path resources = temporaryFolder.newFolder("resources").toPath();
+
+    Main.compile(
+        optionsWithBootclasspath()
+            .setSources(ImmutableList.of(src.toString()))
+            .setTargetLabel("//foo:foo")
+            .setInjectingRuleKind("foo_library")
+            .setOutput(output.toString())
+            .setResourceOutput(resources.toString())
+            .setProcessors(ImmutableList.of(ClassGeneratingProcessor.class.getName()))
+            .build());
+
+    assertThat(listDirectoryContents(resources)).containsExactly(resources.resolve("g/Gen.class"));
+  }
+
+  private static ImmutableList<Path> listDirectoryContents(Path output) throws IOException {
+    ImmutableList.Builder<Path> paths = ImmutableList.builder();
+    Files.walkFileTree(
+        output,
+        new SimpleFileVisitor<Path>() {
+          @Override
+          public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
+            paths.add(path);
+            return FileVisitResult.CONTINUE;
+          }
+        });
+    return paths.build();
+  }
 }
diff --git a/javatests/com/google/turbine/main/ReducedClasspathTest.java b/javatests/com/google/turbine/main/ReducedClasspathTest.java
index d74c640..2810481 100644
--- a/javatests/com/google/turbine/main/ReducedClasspathTest.java
+++ b/javatests/com/google/turbine/main/ReducedClasspathTest.java
@@ -20,7 +20,8 @@
 import static com.google.common.truth.extensions.proto.ProtoTruth.assertThat;
 import static com.google.turbine.testing.TestClassPaths.optionsWithBootclasspath;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
+import static java.util.Objects.requireNonNull;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -106,7 +107,7 @@
     try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(lib))) {
       for (String className : classNames) {
         jos.putNextEntry(new JarEntry(className + ".class"));
-        jos.write(compiled.get(className));
+        jos.write(requireNonNull(compiled.get(className), className));
       }
     }
     return lib;
@@ -231,19 +232,19 @@
 
     Path output = temporaryFolder.newFile("output.jar").toPath();
 
-    try {
-      Main.compile(
-          optionsWithBootclasspath()
-              .setOutput(output.toString())
-              .setSources(ImmutableList.of(src.toString()))
-              .setReducedClasspathMode(ReducedClasspathMode.JAVABUILDER_REDUCED)
-              .setClassPath(ImmutableList.of(libc.toString()))
-              .setDepsArtifacts(ImmutableList.of(libcJdeps.toString()))
-              .build());
-      fail();
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().contains("could not resolve I");
-    }
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Main.compile(
+                    optionsWithBootclasspath()
+                        .setOutput(output.toString())
+                        .setSources(ImmutableList.of(src.toString()))
+                        .setReducedClasspathMode(ReducedClasspathMode.JAVABUILDER_REDUCED)
+                        .setClassPath(ImmutableList.of(libc.toString()))
+                        .setDepsArtifacts(ImmutableList.of(libcJdeps.toString()))
+                        .build()));
+    assertThat(e).hasMessageThat().contains("could not resolve I");
   }
 
   static String lines(String... lines) {
diff --git a/javatests/com/google/turbine/options/TurbineOptionsTest.java b/javatests/com/google/turbine/options/TurbineOptionsTest.java
index d4b468b..5d892c5 100644
--- a/javatests/com/google/turbine/options/TurbineOptionsTest.java
+++ b/javatests/com/google/turbine/options/TurbineOptionsTest.java
@@ -18,6 +18,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth8.assertThat;
+import static org.junit.Assert.assertThrows;
 import static org.junit.Assert.fail;
 
 import com.google.common.collect.ImmutableList;
@@ -283,23 +284,23 @@
 
   @Test
   public void unknownOption() throws Exception {
-    try {
-      TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList("--nosuch")));
-      fail();
-    } catch (IllegalArgumentException e) {
-      assertThat(e).hasMessageThat().contains("unknown option");
-    }
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList("--nosuch"))));
+    assertThat(e).hasMessageThat().contains("unknown option");
   }
 
   @Test
   public void unterminatedJavacopts() throws Exception {
-    try {
-      TurbineOptionsParser.parse(
-          Iterables.concat(BASE_ARGS, Arrays.asList("--javacopts", "--release", "8")));
-      fail();
-    } catch (IllegalArgumentException e) {
-      assertThat(e).hasMessageThat().contains("javacopts should be terminated by `--`");
-    }
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                TurbineOptionsParser.parse(
+                    Iterables.concat(BASE_ARGS, Arrays.asList("--javacopts", "--release", "8"))));
+    assertThat(e).hasMessageThat().contains("javacopts should be terminated by `--`");
   }
 
   @Test
@@ -348,11 +349,9 @@
   @Test
   public void invalidUnescape() throws Exception {
     String[] lines = {"--sources", "'Foo$Bar.java"};
-    try {
-      TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines)));
-      fail();
-    } catch (IllegalArgumentException expected) {
-    }
+    assertThrows(
+        IllegalArgumentException.class,
+        () -> TurbineOptionsParser.parse(Iterables.concat(BASE_ARGS, Arrays.asList(lines))));
   }
 
   @Test
@@ -373,4 +372,37 @@
       assertThat(options.reducedClasspathMode()).isEqualTo(mode);
     }
   }
+
+  @Test
+  public void javaBuilderCompatibility() throws Exception {
+    TurbineOptions options =
+        TurbineOptionsParser.parse(
+            Iterables.concat(
+                BASE_ARGS,
+                ImmutableList.of(
+                    "--output_deps_proto",
+                    "output_deps.proto",
+                    "--generated_sources_output",
+                    "generated_sources.jar",
+                    "--experimental_fix_deps_tool",
+                    "ignored",
+                    "--strict_java_deps",
+                    "ignored",
+                    "--native_header_output",
+                    "ignored",
+                    "--compress_jar")));
+    assertThat(options.outputDeps()).hasValue("output_deps.proto");
+    assertThat(options.gensrcOutput()).hasValue("generated_sources.jar");
+  }
+
+  @Test
+  public void requiredValue() throws Exception {
+    IllegalArgumentException e =
+        assertThrows(
+            IllegalArgumentException.class,
+            () ->
+                TurbineOptionsParser.parse(
+                    Iterables.concat(BASE_ARGS, ImmutableList.of("--output", "--system"))));
+    assertThat(e).hasMessageThat().contains("missing required argument for: --output");
+  }
 }
diff --git a/javatests/com/google/turbine/parse/JavacLexer.java b/javatests/com/google/turbine/parse/JavacLexer.java
index d8939f1..6e1a984 100644
--- a/javatests/com/google/turbine/parse/JavacLexer.java
+++ b/javatests/com/google/turbine/parse/JavacLexer.java
@@ -27,7 +27,7 @@
 import java.util.List;
 
 /** A javac-based reference lexer. */
-public class JavacLexer {
+public final class JavacLexer {
 
   static List<String> javacLex(final String input) {
     Context context = new Context();
@@ -283,4 +283,6 @@
     }
     return token.kind.toString();
   }
+
+  private JavacLexer() {}
 }
diff --git a/javatests/com/google/turbine/parse/LexerTest.java b/javatests/com/google/turbine/parse/LexerTest.java
index 8530d52..c3d7804 100644
--- a/javatests/com/google/turbine/parse/LexerTest.java
+++ b/javatests/com/google/turbine/parse/LexerTest.java
@@ -328,6 +328,11 @@
     lexerComparisonTest("foo /*/*/ bar");
   }
 
+  @Test
+  public void unicode() {
+    lexerComparisonTest("import pkg\uD800\uDC00.test;");
+  }
+
   private void lexerComparisonTest(String s) {
     assertThat(lex(s)).containsExactlyElementsIn(JavacLexer.javacLex(s));
   }
diff --git a/javatests/com/google/turbine/parse/ParseErrorTest.java b/javatests/com/google/turbine/parse/ParseErrorTest.java
index 6a9ad11..eeb3923 100644
--- a/javatests/com/google/turbine/parse/ParseErrorTest.java
+++ b/javatests/com/google/turbine/parse/ParseErrorTest.java
@@ -17,7 +17,7 @@
 package com.google.turbine.parse;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.turbine.diag.SourceFile;
@@ -36,12 +36,8 @@
         new StreamLexer(
             new UnicodeEscapePreprocessor(new SourceFile("<>", String.valueOf("2147483648"))));
     ConstExpressionParser parser = new ConstExpressionParser(lexer, lexer.next());
-    try {
-      parser.expression();
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().contains("invalid literal");
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> parser.expression());
+    assertThat(e).hasMessageThat().contains("invalid literal");
   }
 
   @Test
@@ -50,233 +46,252 @@
         new StreamLexer(
             new UnicodeEscapePreprocessor(new SourceFile("<>", String.valueOf("0x100000000"))));
     ConstExpressionParser parser = new ConstExpressionParser(lexer, lexer.next());
-    try {
-      parser.expression();
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e).hasMessageThat().contains("invalid literal");
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> parser.expression());
+    assertThat(e).hasMessageThat().contains("invalid literal");
   }
 
   @Test
   public void unexpectedTopLevel() {
     String input = "public static void main(String[] args) {}";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unexpected token: void",
-                  "public static void main(String[] args) {}",
-                  "              ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected token: void",
+                "public static void main(String[] args) {}",
+                "              ^"));
   }
 
   @Test
   public void unexpectedIdentifier() {
     String input = "public clas Test {}";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unexpected identifier 'clas'", //
-                  "public clas Test {}",
-                  "       ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected identifier 'clas'", //
+                "public clas Test {}",
+                "       ^"));
   }
 
   @Test
   public void missingTrailingCloseBrace() {
     String input = "public class Test {\n\n";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:2: error: unexpected end of input", //
-                  "",
-                  "^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:2: error: unexpected end of input", //
+                "",
+                "^"));
   }
 
   @Test
   public void annotationArgument() {
     String input = "@A(x = System.err.println()) class Test {}\n";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: invalid annotation argument", //
-                  "@A(x = System.err.println()) class Test {}",
-                  "                         ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: invalid annotation argument", //
+                "@A(x = System.err.println()) class Test {}",
+                "                         ^"));
   }
 
   @Test
   public void dropParens() {
     String input = "enum E { ONE(";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unexpected end of input", //
-                  "enum E { ONE(",
-                  "            ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected end of input", //
+                "enum E { ONE(",
+                "            ^"));
   }
 
   @Test
   public void dropBlocks() {
     String input = "class T { Object f = new Object() {";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unexpected end of input", //
-                  "class T { Object f = new Object() {",
-                  "                                  ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected end of input", //
+                "class T { Object f = new Object() {",
+                "                                  ^"));
   }
 
   @Test
   public void unterminatedString() {
     String input = "class T { String s = \"hello\nworld\"; }";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unterminated string literal", //
-                  "class T { String s = \"hello",
-                  "                           ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unterminated string literal", //
+                "class T { String s = \"hello",
+                "                           ^"));
   }
 
   @Test
   public void emptyChar() {
     String input = "class T { char c = ''; }";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: empty char literal", //
-                  "class T { char c = ''; }",
-                  "                    ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: empty char literal", //
+                "class T { char c = ''; }",
+                "                    ^"));
   }
 
   @Test
   public void unterminatedChar() {
     String input = "class T { char c = '; }";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unterminated char literal", //
-                  "class T { char c = '; }",
-                  "                     ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unterminated char literal", //
+                "class T { char c = '; }",
+                "                     ^"));
   }
 
   @Test
   public void unterminatedExpr() {
     String input = "class T { String s = hello + world }";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unterminated expression, expected ';' not found", //
-                  "class T { String s = hello + world }",
-                  "                     ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unterminated expression, expected ';' not found", //
+                "class T { String s = hello + world }",
+                "                     ^"));
   }
 
   @Test
   public void abruptMultivariableDeclaration() {
     String input = "class T { int x,; }";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: expected token <identifier>", //
-                  "class T { int x,; }",
-                  "                ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: expected token <identifier>", //
+                "class T { int x,; }",
+                "                ^"));
   }
 
   @Test
   public void invalidAnnotation() {
     String input = "@Foo(x =  @E [] x) class T {}";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: invalid annotation argument", //
-                  "@Foo(x =  @E [] x) class T {}",
-                  "                ^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: invalid annotation argument", //
+                "@Foo(x =  @E [] x) class T {}",
+                "                ^"));
   }
 
   @Test
   public void unclosedComment() {
     String input = "/** *\u001a/ class Test {}";
-    try {
-      Parser.parse(input);
-      fail("expected parsing to fail");
-    } catch (TurbineError e) {
-      assertThat(e)
-          .hasMessageThat()
-          .isEqualTo(
-              lines(
-                  "<>:1: error: unclosed comment", //
-                  "/** *\u001a/ class Test {}",
-                  "^"));
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unclosed comment", //
+                "/** *\u001a/ class Test {}",
+                "^"));
+  }
+
+  @Test
+  public void unclosedGenerics() {
+    String input = "enum\te{l;p u@.<@";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected end of input", //
+                "enum\te{l;p u@.<@",
+                "               ^"));
+  }
+
+  @Test
+  public void arrayDot() {
+    String input = "enum\te{p;ullt[].<~>>>L\0";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected token: <", //
+                "enum\te{p;ullt[].<~>>>L\0",
+                "                ^"));
+  }
+
+  @Test
+  public void implementsBeforeExtends() {
+    String input = "class T implements A extends B {}";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: 'extends' must come before 'implements'",
+                "class T implements A extends B {}",
+                "                     ^"));
+  }
+
+  @Test
+  public void unpairedSurrogate() {
+    String input = "import pkg\uD800.PackageTest;";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unpaired surrogate 0xd800",
+                "import pkg\uD800.PackageTest;",
+                "           ^"));
+  }
+
+  @Test
+  public void abruptSurrogate() {
+    String input = "import pkg\uD800";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines("<>:1: error: unpaired surrogate 0xd800", "import pkg\uD800", "          ^"));
+  }
+
+  @Test
+  public void unexpectedSurrogate() {
+    String input = "..\uD800\uDC00";
+    TurbineError e = assertThrows(TurbineError.class, () -> Parser.parse(input));
+    assertThat(e)
+        .hasMessageThat()
+        .isEqualTo(
+            lines(
+                "<>:1: error: unexpected input: U+10000", //
+                "..\uD800\uDC00",
+                "   ^"));
   }
 
   private static String lines(String... lines) {
diff --git a/javatests/com/google/turbine/parse/UnicodeEscapePreprocessorTest.java b/javatests/com/google/turbine/parse/UnicodeEscapePreprocessorTest.java
index e3f7b63..b3e09b8 100644
--- a/javatests/com/google/turbine/parse/UnicodeEscapePreprocessorTest.java
+++ b/javatests/com/google/turbine/parse/UnicodeEscapePreprocessorTest.java
@@ -18,7 +18,7 @@
 
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.turbine.diag.SourceFile;
 import com.google.turbine.diag.TurbineError;
@@ -57,19 +57,11 @@
 
   @Test
   public void abruptEnd() {
-    try {
-      readAll("\\u00");
-      fail();
-    } catch (TurbineError e) {
-      assertThat(getOnlyElement(e.diagnostics()).kind()).isEqualTo(ErrorKind.UNEXPECTED_EOF);
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> readAll("\\u00"));
+    assertThat(getOnlyElement(e.diagnostics()).kind()).isEqualTo(ErrorKind.UNEXPECTED_EOF);
 
-    try {
-      readAll("\\u");
-      fail();
-    } catch (TurbineError e) {
-      assertThat(getOnlyElement(e.diagnostics()).kind()).isEqualTo(ErrorKind.UNEXPECTED_EOF);
-    }
+    e = assertThrows(TurbineError.class, () -> readAll("\\u"));
+    assertThat(getOnlyElement(e.diagnostics()).kind()).isEqualTo(ErrorKind.UNEXPECTED_EOF);
   }
 
   @Test
@@ -79,19 +71,16 @@
 
   @Test
   public void invalidEscape() {
-    try {
-      readAll("\\uUUUU");
-      fail();
-    } catch (TurbineError e) {
-      assertThat(getOnlyElement(e.diagnostics()).kind()).isEqualTo(ErrorKind.INVALID_UNICODE);
-    }
+    TurbineError e = assertThrows(TurbineError.class, () -> readAll("\\uUUUU"));
+    assertThat(getOnlyElement(e.diagnostics()).kind()).isEqualTo(ErrorKind.INVALID_UNICODE);
   }
 
   private List<Character> readAll(String input) {
     UnicodeEscapePreprocessor reader = new UnicodeEscapePreprocessor(new SourceFile(null, input));
     List<Character> result = new ArrayList<>();
-    for (char ch = reader.next(); ch != UnicodeEscapePreprocessor.ASCII_SUB; ch = reader.next()) {
-      result.add(ch);
+    for (int ch = reader.next(); ch != UnicodeEscapePreprocessor.ASCII_SUB; ch = reader.next()) {
+      assertThat(Character.isBmpCodePoint(ch)).isTrue();
+      result.add((char) ch);
     }
     return result;
   }
diff --git a/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java b/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java
index e6a59bf..d3b3836 100644
--- a/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java
+++ b/javatests/com/google/turbine/processing/AbstractTurbineTypesTest.java
@@ -21,6 +21,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
 
 import com.google.common.base.Joiner;
@@ -231,6 +232,14 @@
         "Float",
         "Double",
       },
+      // type annotations
+      {
+        "@A List<@B Integer>",
+        "@A List",
+        "@A int @B []",
+        "@A List<@A int @B []>",
+        "Map.@A Entry<@B Integer, @C Number>",
+      },
     };
     List<String> files = new ArrayList<>();
     AtomicInteger idx = new AtomicInteger();
@@ -242,6 +251,7 @@
               "package p;",
               "import java.util.*;",
               "import java.io.*;",
+              "import java.lang.annotation.*;",
               String.format("abstract class Test%s {", idx.getAndIncrement()),
               Streams.mapWithIndex(
                       Arrays.stream(group), (x, i) -> String.format("  %s f%d;\n", x, i))
@@ -250,6 +260,9 @@
               "  abstract <V extends List<V>> V g();",
               "  abstract <W extends ArrayList> W h();",
               "  abstract <X extends Serializable> X i();",
+              "  @Target(ElementType.TYPE_USE) @interface A {}",
+              "  @Target(ElementType.TYPE_USE) @interface B {}",
+              "  @Target(ElementType.TYPE_USE) @interface C {}",
               "}");
       String content = sb.toString();
       files.add(content);
@@ -397,8 +410,11 @@
 
       ListMultimap<String, TypeMirror> turbineInputs =
           MultimapBuilder.linkedHashKeys().arrayListValues().build();
-      turbineElements
-          .get(name)
+      /*
+       * requireNonNull is safe because `name` is from `javacElements`, which we checked has the
+       * same keys as `turbineElements`.
+       */
+      requireNonNull(turbineElements.get(name))
           .getEnclosedElements()
           .forEach(e -> getTypes(turbineTypes, e, turbineInputs));
 
diff --git a/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java b/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java
index ed5af6a..96664d2 100644
--- a/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java
+++ b/javatests/com/google/turbine/processing/ProcessingIntegrationTest.java
@@ -19,9 +19,11 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth8.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
 import static java.util.stream.Collectors.joining;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.base.Splitter;
@@ -33,6 +35,7 @@
 import com.google.turbine.binder.Processing;
 import com.google.turbine.binder.Processing.ProcessorInfo;
 import com.google.turbine.diag.SourceFile;
+import com.google.turbine.diag.TurbineDiagnostic;
 import com.google.turbine.diag.TurbineError;
 import com.google.turbine.lower.IntegrationTestSupport;
 import com.google.turbine.parse.Parser;
@@ -45,9 +48,11 @@
 import java.util.Optional;
 import java.util.Set;
 import javax.annotation.processing.AbstractProcessor;
+import javax.annotation.processing.ProcessingEnvironment;
 import javax.annotation.processing.RoundEnvironment;
 import javax.annotation.processing.SupportedAnnotationTypes;
 import javax.lang.model.SourceVersion;
+import javax.lang.model.element.AnnotationMirror;
 import javax.lang.model.element.TypeElement;
 import javax.tools.Diagnostic;
 import javax.tools.JavaFileObject;
@@ -73,39 +78,33 @@
     }
   }
 
-  private static final IntegrationTestSupport.TestInput SOURCES =
-      IntegrationTestSupport.TestInput.parse(
-          Joiner.on('\n')
-              .join(
-                  "=== Test.java ===", //
-                  "@Deprecated",
-                  "class Test extends NoSuch {",
-                  "}"));
-
   @Test
   public void crash() throws IOException {
     ImmutableList<Tree.CompUnit> units =
-        SOURCES.sources.entrySet().stream()
-            .map(e -> new SourceFile(e.getKey(), e.getValue()))
-            .map(Parser::parse)
-            .collect(toImmutableList());
-    try {
-      Binder.bind(
-          units,
-          ClassPathBinder.bindClasspath(ImmutableList.of()),
-          Processing.ProcessorInfo.create(
-              ImmutableList.of(new CrashingProcessor()),
-              getClass().getClassLoader(),
-              ImmutableMap.of(),
-              SourceVersion.latestSupported()),
-          TestClassPaths.TURBINE_BOOTCLASSPATH,
-          Optional.empty());
-      fail();
-    } catch (TurbineError e) {
-      assertThat(e.diagnostics()).hasSize(2);
-      assertThat(e.diagnostics().get(0).message()).contains("could not resolve NoSuch");
-      assertThat(e.diagnostics().get(1).message()).contains("crash!");
-    }
+        parseUnit(
+            "=== Test.java ===", //
+            "@Deprecated",
+            "class Test extends NoSuch {",
+            "}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    Processing.ProcessorInfo.create(
+                        ImmutableList.of(new CrashingProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    ImmutableList<String> messages =
+        e.diagnostics().stream().map(TurbineDiagnostic::message).collect(toImmutableList());
+    assertThat(messages).hasSize(2);
+    assertThat(messages.get(0)).contains("could not resolve NoSuch");
+    assertThat(messages.get(1)).contains("crash!");
   }
 
   @SupportedAnnotationTypes("*")
@@ -142,38 +141,30 @@
   @Test
   public void warnings() throws IOException {
     ImmutableList<Tree.CompUnit> units =
-        IntegrationTestSupport.TestInput.parse(
-                Joiner.on('\n')
-                    .join(
-                        "=== Test.java ===", //
-                        "@Deprecated",
-                        "class Test {",
-                        "}"))
-            .sources
-            .entrySet()
-            .stream()
-            .map(e -> new SourceFile(e.getKey(), e.getValue()))
-            .map(Parser::parse)
-            .collect(toImmutableList());
-    try {
-      Binder.bind(
-          units,
-          ClassPathBinder.bindClasspath(ImmutableList.of()),
-          Processing.ProcessorInfo.create(
-              ImmutableList.of(new WarningProcessor()),
-              getClass().getClassLoader(),
-              ImmutableMap.of(),
-              SourceVersion.latestSupported()),
-          TestClassPaths.TURBINE_BOOTCLASSPATH,
-          Optional.empty());
-      fail();
-    } catch (TurbineError e) {
-      ImmutableList<String> diags =
-          e.diagnostics().stream().map(d -> d.message()).collect(toImmutableList());
-      assertThat(diags).hasSize(2);
-      assertThat(diags.get(0)).contains("proc warning");
-      assertThat(diags.get(1)).contains("proc error");
-    }
+        parseUnit(
+            "=== Test.java ===", //
+            "@Deprecated",
+            "class Test {",
+            "}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    Processing.ProcessorInfo.create(
+                        ImmutableList.of(new WarningProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    ImmutableList<String> diags =
+        e.diagnostics().stream().map(d -> d.message()).collect(toImmutableList());
+    assertThat(diags).hasSize(2);
+    assertThat(diags.get(0)).contains("proc warning");
+    assertThat(diags.get(1)).contains("proc error");
   }
 
   @SupportedAnnotationTypes("*")
@@ -219,19 +210,11 @@
   @Test
   public void resources() throws IOException {
     ImmutableList<Tree.CompUnit> units =
-        IntegrationTestSupport.TestInput.parse(
-                Joiner.on('\n')
-                    .join(
-                        "=== Test.java ===", //
-                        "@Deprecated",
-                        "class Test {",
-                        "}"))
-            .sources
-            .entrySet()
-            .stream()
-            .map(e -> new SourceFile(e.getKey(), e.getValue()))
-            .map(Parser::parse)
-            .collect(toImmutableList());
+        parseUnit(
+            "=== Test.java ===", //
+            "@Deprecated",
+            "class Test {",
+            "}");
     BindingResult bound =
         Binder.bind(
             units,
@@ -247,34 +230,27 @@
     assertThat(bound.generatedSources().keySet()).containsExactly("Gen.java", "source.txt");
     assertThat(bound.generatedClasses().keySet()).containsExactly("class.txt");
 
-    assertThat(bound.generatedSources().get("source.txt").source())
+    // The requireNonNull calls are safe because of the keySet checks above.
+    assertThat(requireNonNull(bound.generatedSources().get("source.txt")).source())
         .isEqualTo("hello source output");
-    assertThat(new String(bound.generatedClasses().get("class.txt"), UTF_8))
+    assertThat(new String(requireNonNull(bound.generatedClasses().get("class.txt")), UTF_8))
         .isEqualTo("hello class output");
   }
 
   @Test
   public void getAllAnnotations() throws IOException {
     ImmutableList<Tree.CompUnit> units =
-        IntegrationTestSupport.TestInput.parse(
-                Joiner.on('\n')
-                    .join(
-                        "=== A.java ===", //
-                        "import java.lang.annotation.Inherited;",
-                        "@Inherited",
-                        "@interface A {}",
-                        "=== B.java ===", //
-                        "@interface B {}",
-                        "=== One.java ===", //
-                        "@A @B class One {}",
-                        "=== Two.java ===", //
-                        "class Two extends One {}"))
-            .sources
-            .entrySet()
-            .stream()
-            .map(e -> new SourceFile(e.getKey(), e.getValue()))
-            .map(Parser::parse)
-            .collect(toImmutableList());
+        parseUnit(
+            "=== A.java ===", //
+            "import java.lang.annotation.Inherited;",
+            "@Inherited",
+            "@interface A {}",
+            "=== B.java ===", //
+            "@interface B {}",
+            "=== One.java ===", //
+            "@A @B class One {}",
+            "=== Two.java ===", //
+            "class Two extends One {}");
     BindingResult bound =
         Binder.bind(
             units,
@@ -343,4 +319,308 @@
                   .collect(joining(", ")));
     }
   }
+
+  private static void logError(
+      ProcessingEnvironment processingEnv,
+      RoundEnvironment roundEnv,
+      Class<?> processorClass,
+      int round) {
+    processingEnv
+        .getMessager()
+        .printMessage(
+            Diagnostic.Kind.ERROR,
+            String.format(
+                "%d: %s {errorRaised=%s, processingOver=%s}",
+                round,
+                processorClass.getSimpleName(),
+                roundEnv.errorRaised(),
+                roundEnv.processingOver()));
+  }
+
+  @SupportedAnnotationTypes("*")
+  public static class ErrorProcessor extends AbstractProcessor {
+
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    int round = 0;
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      int round = ++this.round;
+      logError(processingEnv, roundEnv, getClass(), round);
+      String name = "Gen" + round;
+      try (Writer writer = processingEnv.getFiler().createSourceFile(name).openWriter()) {
+        writer.write(String.format("class %s {}", name));
+      } catch (IOException e) {
+        throw new UncheckedIOException(e);
+      }
+      return false;
+    }
+  }
+
+  @SupportedAnnotationTypes("*")
+  public static class FinalRoundErrorProcessor extends AbstractProcessor {
+
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    int round = 0;
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      int round = ++this.round;
+      if (roundEnv.processingOver()) {
+        logError(processingEnv, roundEnv, getClass(), round);
+      }
+      return false;
+    }
+  }
+
+  @Test
+  public void errorsAndFinalRound() throws IOException {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== Test.java ===", //
+            "@Deprecated",
+            "class Test {",
+            "}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    Processing.ProcessorInfo.create(
+                        ImmutableList.of(new ErrorProcessor(), new FinalRoundErrorProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    ImmutableList<String> diags =
+        e.diagnostics().stream().map(d -> d.message()).collect(toImmutableList());
+    assertThat(diags)
+        .containsExactly(
+            "1: ErrorProcessor {errorRaised=false, processingOver=false}",
+            "2: ErrorProcessor {errorRaised=true, processingOver=true}",
+            "2: FinalRoundErrorProcessor {errorRaised=true, processingOver=true}")
+        .inOrder();
+  }
+
+  @SupportedAnnotationTypes("*")
+  public static class SuperTypeProcessor extends AbstractProcessor {
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      TypeElement typeElement = processingEnv.getElementUtils().getTypeElement("T");
+      processingEnv
+          .getMessager()
+          .printMessage(
+              Diagnostic.Kind.ERROR,
+              typeElement.getSuperclass()
+                  + " "
+                  + processingEnv.getTypeUtils().directSupertypes(typeElement.asType()));
+      return false;
+    }
+  }
+
+  @Test
+  public void superType() throws IOException {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== T.java ===", //
+            "@Deprecated",
+            "class T extends S {",
+            "}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    Processing.ProcessorInfo.create(
+                        ImmutableList.of(new SuperTypeProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    ImmutableList<String> diags =
+        e.diagnostics().stream().map(d -> d.message()).collect(toImmutableList());
+    assertThat(diags).containsExactly("could not resolve S", "S [S]").inOrder();
+  }
+
+  @SupportedAnnotationTypes("*")
+  public static class GenerateAnnotationProcessor extends AbstractProcessor {
+
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    private boolean first = true;
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      if (first) {
+        try {
+          JavaFileObject file = processingEnv.getFiler().createSourceFile("A");
+          try (Writer writer = file.openWriter()) {
+            writer.write("@interface A {}");
+          }
+        } catch (IOException e) {
+          throw new UncheckedIOException(e);
+        }
+        first = false;
+      }
+      return false;
+    }
+  }
+
+  @Test
+  public void generatedAnnotationDefinition() throws IOException {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== T.java ===", //
+            "@interface B {",
+            "  A value() default @A;",
+            "}",
+            "@B(value = @A)",
+            "class T {",
+            "}");
+    BindingResult bound =
+        Binder.bind(
+            units,
+            ClassPathBinder.bindClasspath(ImmutableList.of()),
+            ProcessorInfo.create(
+                ImmutableList.of(new GenerateAnnotationProcessor()),
+                getClass().getClassLoader(),
+                ImmutableMap.of(),
+                SourceVersion.latestSupported()),
+            TestClassPaths.TURBINE_BOOTCLASSPATH,
+            Optional.empty());
+    assertThat(bound.generatedSources()).containsKey("A.java");
+  }
+
+  @SupportedAnnotationTypes("*")
+  public static class GenerateQualifiedProcessor extends AbstractProcessor {
+
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      String superType =
+          processingEnv.getElementUtils().getTypeElement("T").getSuperclass().toString();
+      processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, superType);
+      return false;
+    }
+  }
+
+  @Test
+  public void qualifiedErrorType() throws IOException {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== T.java ===", //
+            "class T extends G.I {",
+            "}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    ProcessorInfo.create(
+                        ImmutableList.of(new GenerateQualifiedProcessor()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    assertThat(
+            e.diagnostics().stream()
+                .filter(d -> d.severity().equals(Diagnostic.Kind.NOTE))
+                .map(d -> d.message()))
+        .containsExactly("G.I");
+  }
+
+  @SupportedAnnotationTypes("*")
+  public static class ElementValueInspector extends AbstractProcessor {
+
+    @Override
+    public SourceVersion getSupportedSourceVersion() {
+      return SourceVersion.latestSupported();
+    }
+
+    @Override
+    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
+      TypeElement element = processingEnv.getElementUtils().getTypeElement("T");
+      for (AnnotationMirror annotationMirror : element.getAnnotationMirrors()) {
+        processingEnv
+            .getMessager()
+            .printMessage(
+                Diagnostic.Kind.NOTE,
+                String.format("@Deprecated(%s)", annotationMirror.getElementValues()),
+                element,
+                annotationMirror);
+      }
+      return false;
+    }
+  }
+
+  @Test
+  public void badElementValue() throws IOException {
+    ImmutableList<Tree.CompUnit> units =
+        parseUnit(
+            "=== T.java ===", //
+            "@Deprecated(noSuch = 42) class T {}");
+    TurbineError e =
+        assertThrows(
+            TurbineError.class,
+            () ->
+                Binder.bind(
+                    units,
+                    ClassPathBinder.bindClasspath(ImmutableList.of()),
+                    ProcessorInfo.create(
+                        ImmutableList.of(new ElementValueInspector()),
+                        getClass().getClassLoader(),
+                        ImmutableMap.of(),
+                        SourceVersion.latestSupported()),
+                    TestClassPaths.TURBINE_BOOTCLASSPATH,
+                    Optional.empty()));
+    assertThat(
+            e.diagnostics().stream()
+                .filter(d -> d.severity().equals(Diagnostic.Kind.ERROR))
+                .map(d -> d.message()))
+        .containsExactly("could not resolve element noSuch() in java.lang.Deprecated");
+    assertThat(
+            e.diagnostics().stream()
+                .filter(d -> d.severity().equals(Diagnostic.Kind.NOTE))
+                .map(d -> d.message()))
+        .containsExactly("@Deprecated({})");
+  }
+
+  private static ImmutableList<Tree.CompUnit> parseUnit(String... lines) {
+    return IntegrationTestSupport.TestInput.parse(Joiner.on('\n').join(lines))
+        .sources
+        .entrySet()
+        .stream()
+        .map(e -> new SourceFile(e.getKey(), e.getValue()))
+        .map(Parser::parse)
+        .collect(toImmutableList());
+  }
 }
diff --git a/javatests/com/google/turbine/processing/TurbineAnnotationProxyTest.java b/javatests/com/google/turbine/processing/TurbineAnnotationProxyTest.java
index d339700..a8c00aa 100644
--- a/javatests/com/google/turbine/processing/TurbineAnnotationProxyTest.java
+++ b/javatests/com/google/turbine/processing/TurbineAnnotationProxyTest.java
@@ -18,11 +18,11 @@
 
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static com.google.turbine.testing.TestResources.getResourceBytes;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
-import com.google.common.io.ByteStreams;
 import com.google.common.primitives.Ints;
 import com.google.common.testing.EqualsTester;
 import com.google.turbine.binder.Binder;
@@ -39,7 +39,6 @@
 import com.google.turbine.testing.TestClassPaths;
 import com.google.turbine.tree.Tree.CompUnit;
 import java.io.IOException;
-import java.io.InputStream;
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Inherited;
 import java.lang.annotation.Repeatable;
@@ -161,17 +160,13 @@
 
     assertThat(a.b().value()).isEqualTo(-1);
     assertThat(a.e()).isEqualTo(ElementType.PACKAGE);
-    try {
-      a.c();
-      fail();
-    } catch (MirroredTypeException e) {
+    {
+      MirroredTypeException e = assertThrows(MirroredTypeException.class, () -> a.c());
       assertThat(e.getTypeMirror().getKind()).isEqualTo(TypeKind.DECLARED);
       assertThat(getQualifiedName(e.getTypeMirror())).contains("java.lang.String");
     }
-    try {
-      a.cx();
-      fail();
-    } catch (MirroredTypesException e) {
+    {
+      MirroredTypesException e = assertThrows(MirroredTypesException.class, () -> a.cx());
       assertThat(
               e.getTypeMirrors().stream().map(m -> getQualifiedName(m)).collect(toImmutableList()))
           .containsExactly("java.lang.Integer", "java.lang.Long");
@@ -208,9 +203,7 @@
   private static void addClass(JarOutputStream jos, Class<?> clazz) throws IOException {
     String entryPath = clazz.getName().replace('.', '/') + ".class";
     jos.putNextEntry(new JarEntry(entryPath));
-    try (InputStream is = clazz.getClassLoader().getResourceAsStream(entryPath)) {
-      ByteStreams.copy(is, jos);
-    }
+    jos.write(getResourceBytes(clazz, "/" + entryPath));
   }
 
   private static String getQualifiedName(TypeMirror typeMirror) {
diff --git a/javatests/com/google/turbine/processing/TurbineElementsTest.java b/javatests/com/google/turbine/processing/TurbineElementsTest.java
index 770e6f6..281bde4 100644
--- a/javatests/com/google/turbine/processing/TurbineElementsTest.java
+++ b/javatests/com/google/turbine/processing/TurbineElementsTest.java
@@ -20,6 +20,8 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.collect.MoreCollectors.onlyElement;
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.Truth.assertWithMessage;
+import static com.google.common.truth.TruthJUnit.assume;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -225,6 +227,12 @@
         .isFalse();
     assertThat(turbineElements.isDeprecated(turbineElements.getTypeElement("One"))).isFalse();
     assertThat(turbineElements.isDeprecated(turbineElements.getTypeElement("Test"))).isTrue();
+    for (Element e : turbineElements.getTypeElement("java.lang.Object").getEnclosedElements()) {
+      assume().that(e.getSimpleName().contentEquals("finalize")).isFalse();
+      assertWithMessage(e.getSimpleName().toString())
+          .that(turbineElements.isDeprecated(e))
+          .isFalse();
+    }
   }
 
   @Test
diff --git a/javatests/com/google/turbine/processing/TurbineFilerTest.java b/javatests/com/google/turbine/processing/TurbineFilerTest.java
index 40b78ea..d433428 100644
--- a/javatests/com/google/turbine/processing/TurbineFilerTest.java
+++ b/javatests/com/google/turbine/processing/TurbineFilerTest.java
@@ -19,7 +19,7 @@
 import static com.google.common.collect.Iterables.getOnlyElement;
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Function;
 import com.google.common.base.Supplier;
@@ -98,19 +98,13 @@
     seen.add("com/foo/Bar.java");
     seen.add("com/foo/Baz.class");
 
-    try {
-      filer.createSourceFile("com.foo.Bar", (Element[]) null);
-      fail();
-    } catch (FilerException expected) {
-    }
+    assertThrows(
+        FilerException.class, () -> filer.createSourceFile("com.foo.Bar", (Element[]) null));
     filer.createSourceFile("com.foo.Baz", (Element[]) null);
 
     filer.createClassFile("com.foo.Bar", (Element[]) null);
-    try {
-      filer.createClassFile("com.foo.Baz", (Element[]) null);
-      fail();
-    } catch (FilerException expected) {
-    }
+    assertThrows(
+        FilerException.class, () -> filer.createClassFile("com.foo.Baz", (Element[]) null));
   }
 
   @Test
@@ -121,11 +115,7 @@
             StandardLocation.SOURCE_OUTPUT,
             StandardLocation.ANNOTATION_PROCESSOR_PATH,
             StandardLocation.CLASS_PATH)) {
-      try {
-        filer.getResource(location, "", "NoSuch");
-        fail();
-      } catch (FileNotFoundException expected) {
-      }
+      assertThrows(FileNotFoundException.class, () -> filer.getResource(location, "", "NoSuch"));
     }
   }
 
diff --git a/javatests/com/google/turbine/processing/TurbineMessagerTest.java b/javatests/com/google/turbine/processing/TurbineMessagerTest.java
index c1e6401..017012c 100644
--- a/javatests/com/google/turbine/processing/TurbineMessagerTest.java
+++ b/javatests/com/google/turbine/processing/TurbineMessagerTest.java
@@ -19,6 +19,7 @@
 import static com.google.common.collect.ImmutableList.toImmutableList;
 import static com.google.common.truth.Truth.assertThat;
 import static java.util.Comparator.comparing;
+import static java.util.Objects.requireNonNull;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -242,7 +243,7 @@
 
   private static String shortPath(Diagnostic<? extends JavaFileObject> d) {
     return d.getSource() != null
-        ? Paths.get(d.getSource().getName()).getFileName().toString()
+        ? requireNonNull(Paths.get(d.getSource().getName()).getFileName()).toString()
         : "<>";
   }
 }
diff --git a/javatests/com/google/turbine/processing/TurbineTypesFactoryTest.java b/javatests/com/google/turbine/processing/TurbineTypesFactoryTest.java
index 0f9e6a6..b028a81 100644
--- a/javatests/com/google/turbine/processing/TurbineTypesFactoryTest.java
+++ b/javatests/com/google/turbine/processing/TurbineTypesFactoryTest.java
@@ -17,7 +17,7 @@
 package com.google.turbine.processing;
 
 import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
@@ -83,11 +83,7 @@
         PrimitiveType type = turbineTypes.getPrimitiveType(kind);
         assertThat(type.getKind()).isEqualTo(kind);
       } else {
-        try {
-          turbineTypes.getPrimitiveType(kind);
-          fail();
-        } catch (IllegalArgumentException expected) {
-        }
+        assertThrows(IllegalArgumentException.class, () -> turbineTypes.getPrimitiveType(kind));
       }
     }
   }
@@ -167,11 +163,7 @@
   public void noType() {
     assertThat(turbineTypes.getNoType(TypeKind.VOID).getKind()).isEqualTo(TypeKind.VOID);
     assertThat(turbineTypes.getNoType(TypeKind.NONE).getKind()).isEqualTo(TypeKind.NONE);
-    try {
-      turbineTypes.getNoType(TypeKind.DECLARED);
-      fail();
-    } catch (IllegalArgumentException expected) {
-    }
+    assertThrows(IllegalArgumentException.class, () -> turbineTypes.getNoType(TypeKind.DECLARED));
   }
 
   @Test
diff --git a/javatests/com/google/turbine/processing/TurbineTypesUnaryTest.java b/javatests/com/google/turbine/processing/TurbineTypesUnaryTest.java
index eb5ee6c..00eb571 100644
--- a/javatests/com/google/turbine/processing/TurbineTypesUnaryTest.java
+++ b/javatests/com/google/turbine/processing/TurbineTypesUnaryTest.java
@@ -18,10 +18,11 @@
 
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableSet;
+import com.google.turbine.types.Deannotate;
 import javax.lang.model.type.PrimitiveType;
 import javax.lang.model.type.TypeKind;
 import javax.lang.model.type.TypeMirror;
@@ -68,12 +69,10 @@
       thrown = e;
     }
     if (thrown != null) {
-      try {
-        turbineTypes.unboxedType(turbineA).toString();
-        fail(String.format("expected unboxedType(`%s`) to throw", turbineA));
-      } catch (IllegalArgumentException expected) {
-        // expected
-      }
+      assertThrows(
+          String.format("expected unboxedType(`%s`) to throw", turbineA),
+          IllegalArgumentException.class,
+          () -> turbineTypes.unboxedType(turbineA).toString());
     } else {
       String actual = turbineTypes.unboxedType(turbineA).toString();
       assertWithMessage("unboxedClass(`%s`) = unboxedClass(`%s`)", javacA, turbineA)
@@ -121,16 +120,8 @@
   public void directSupertypesThrows() {
     assume().that(UNSUPPORTED_BY_DIRECT_SUPERTYPES).contains(javacA.getKind());
 
-    try {
-      javacTypes.directSupertypes(turbineA);
-      fail();
-    } catch (IllegalArgumentException expected) {
-    }
-    try {
-      turbineTypes.directSupertypes(turbineA);
-      fail();
-    } catch (IllegalArgumentException expected) {
-    }
+    assertThrows(IllegalArgumentException.class, () -> javacTypes.directSupertypes(turbineA));
+    assertThrows(IllegalArgumentException.class, () -> turbineTypes.directSupertypes(turbineA));
   }
 
   @Test
@@ -144,4 +135,16 @@
         .that(actual)
         .isEqualTo(expected);
   }
+
+  @Test
+  public void deannotate() {
+    String toString = turbineA.toString();
+    String deannotated =
+        Deannotate.deannotate(((TurbineTypeMirror) turbineA).asTurbineType()).toString();
+    if (toString.contains("@")) {
+      assertWithMessage("deannotate(`%s`) = `%s`", toString, deannotated)
+          .that(deannotated)
+          .doesNotContain("@");
+    }
+  }
 }
diff --git a/javatests/com/google/turbine/testing/AsmUtils.java b/javatests/com/google/turbine/testing/AsmUtils.java
index 5b5e102..b7e77bc 100644
--- a/javatests/com/google/turbine/testing/AsmUtils.java
+++ b/javatests/com/google/turbine/testing/AsmUtils.java
@@ -27,14 +27,18 @@
  * ASM-based test utilities, in their own class mostly to avoid namespace issues with e.g. {@link
  * com.google.turbine.bytecode.ClassReader}.
  */
-public class AsmUtils {
-  public static String textify(byte[] bytes) {
+public final class AsmUtils {
+  public static String textify(byte[] bytes, boolean skipDebug) {
     Printer textifier = new Textifier();
     StringWriter sw = new StringWriter();
     new ClassReader(bytes)
         .accept(
             new TraceClassVisitor(null, textifier, new PrintWriter(sw, true)),
-            ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES | ClassReader.SKIP_CODE);
+            ClassReader.SKIP_FRAMES
+                | ClassReader.SKIP_CODE
+                | (skipDebug ? ClassReader.SKIP_DEBUG : 0));
     return sw.toString();
   }
+
+  private AsmUtils() {}
 }
diff --git a/javatests/com/google/turbine/testing/TestClassPaths.java b/javatests/com/google/turbine/testing/TestClassPaths.java
index 93be916..55e8b9e 100644
--- a/javatests/com/google/turbine/testing/TestClassPaths.java
+++ b/javatests/com/google/turbine/testing/TestClassPaths.java
@@ -20,10 +20,9 @@
 
 import com.google.common.base.Splitter;
 import com.google.common.collect.ImmutableList;
-import com.google.common.collect.Streams;
 import com.google.turbine.binder.ClassPath;
 import com.google.turbine.binder.ClassPathBinder;
-import com.google.turbine.binder.CtSymClassBinder;
+import com.google.turbine.binder.JimageClassBinder;
 import com.google.turbine.options.TurbineOptions;
 import java.io.File;
 import java.io.IOException;
@@ -33,15 +32,14 @@
 import java.nio.file.Paths;
 import java.util.Optional;
 
-public class TestClassPaths {
+public final class TestClassPaths {
 
   private static final Splitter CLASS_PATH_SPLITTER =
       Splitter.on(File.pathSeparatorChar).omitEmptyStrings();
 
-  private static final ImmutableList<Path> BOOTCLASSPATH =
-      Streams.stream(
-              CLASS_PATH_SPLITTER.split(
-                  Optional.ofNullable(System.getProperty("sun.boot.class.path")).orElse("")))
+  public static final ImmutableList<Path> BOOTCLASSPATH =
+      CLASS_PATH_SPLITTER
+          .splitToStream(Optional.ofNullable(System.getProperty("sun.boot.class.path")).orElse(""))
           .map(Paths::get)
           .filter(Files::exists)
           .collect(toImmutableList());
@@ -53,7 +51,7 @@
       if (!BOOTCLASSPATH.isEmpty()) {
         return ClassPathBinder.bindClasspath(BOOTCLASSPATH);
       }
-      return CtSymClassBinder.bind("8");
+      return JimageClassBinder.bindDefault();
     } catch (IOException e) {
       e.printStackTrace();
       throw new UncheckedIOException(e);
@@ -74,4 +72,6 @@
     }
     return options;
   }
+
+  private TestClassPaths() {}
 }
diff --git a/javatests/com/google/turbine/testing/TestResources.java b/javatests/com/google/turbine/testing/TestResources.java
new file mode 100644
index 0000000..86c7632
--- /dev/null
+++ b/javatests/com/google/turbine/testing/TestResources.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright 2021 Google Inc. All Rights Reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.google.turbine.testing;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.io.ByteStreams;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.UncheckedIOException;
+
+public final class TestResources {
+
+  public static String getResource(Class<?> clazz, String resource) {
+    return new String(getResourceBytes(clazz, resource), UTF_8);
+  }
+
+  public static byte[] getResourceBytes(Class<?> clazz, String resource) {
+    try (InputStream is = requireNonNull(clazz.getResourceAsStream(resource), resource)) {
+      return ByteStreams.toByteArray(is);
+    } catch (IOException e) {
+      throw new UncheckedIOException(e);
+    }
+  }
+
+  private TestResources() {}
+}
diff --git a/javatests/com/google/turbine/zip/ZipTest.java b/javatests/com/google/turbine/zip/ZipTest.java
index bfc9cdf..0d49e1a 100644
--- a/javatests/com/google/turbine/zip/ZipTest.java
+++ b/javatests/com/google/turbine/zip/ZipTest.java
@@ -18,7 +18,7 @@
 
 import static com.google.common.truth.Truth.assertThat;
 import static java.nio.charset.StandardCharsets.UTF_8;
-import static org.junit.Assert.fail;
+import static org.junit.Assert.assertThrows;
 
 import com.google.common.collect.ImmutableMap;
 import com.google.common.hash.Hashing;
@@ -161,11 +161,7 @@
     }
     Files.write(path, "trailing garbage".getBytes(UTF_8), StandardOpenOption.APPEND);
 
-    try {
-      actual(path);
-      fail();
-    } catch (ZipException e) {
-      assertThat(e).hasMessageThat().isEqualTo("zip file comment length was 33, expected 17");
-    }
+    ZipException e = assertThrows(ZipException.class, () -> actual(path));
+    assertThat(e).hasMessageThat().isEqualTo("zip file comment length was 33, expected 17");
   }
 }
diff --git a/pom.xml b/pom.xml
index fa923f4..dae4b70 100644
--- a/pom.xml
+++ b/pom.xml
@@ -1,4 +1,20 @@
 <?xml version="1.0" encoding="UTF-8"?>
+<!--
+  Copyright 2020 Google Inc.
+
+  Licensed under the Apache License, Version 2.0 (the "License");
+  you may not use this file except in compliance with the License.
+  You may obtain a copy of the License at
+
+      http://www.apache.org/licenses/LICENSE-2.0
+
+  Unless required by applicable law or agreed to in writing, software
+  distributed under the License is distributed on an "AS IS" BASIS,
+  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  See the License for the specific language governing permissions and
+  limitations under the License.
+-->
+
 <project
   xmlns="http://maven.apache.org/POM/4.0.0"
   xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
@@ -7,7 +23,7 @@
 
   <groupId>com.google.turbine</groupId>
   <artifactId>turbine</artifactId>
-  <version>0.1-SNAPSHOT</version>
+  <version>HEAD-SNAPSHOT</version>
 
   <name>turbine</name>
   <description>
@@ -15,9 +31,13 @@
   </description>
 
   <properties>
-    <asm.version>7.0</asm.version>
+    <asm.version>9.1</asm.version>
     <javac.version>9+181-r4173-1</javac.version>
-    <guava.version>27.0.1-jre</guava.version>
+    <guava.version>30.0-jre</guava.version>
+    <errorprone.version>2.7.1</errorprone.version>
+    <maven-javadoc-plugin.version>3.1.0</maven-javadoc-plugin.version>
+    <maven-source-plugin.version>3.2.1</maven-source-plugin.version>
+    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
   </properties>
 
   <dependencies>
@@ -27,19 +47,20 @@
       <version>${guava.version}</version>
     </dependency>
     <dependency>
-      <groupId>com.google.code.findbugs</groupId>
-      <artifactId>jsr305</artifactId>
-      <version>2.0.1</version>
-    </dependency>
-    <dependency>
       <groupId>com.google.errorprone</groupId>
       <artifactId>error_prone_annotations</artifactId>
-      <version>2.0.12</version>
+      <version>${errorprone.version}</version>
+    </dependency>
+    <dependency>
+      <groupId>org.checkerframework</groupId>
+      <artifactId>checker-qual</artifactId>
+      <version>3.9.1</version>
+      <optional>true</optional>
     </dependency>
     <dependency>
       <groupId>com.google.protobuf</groupId>
       <artifactId>protobuf-java</artifactId>
-      <version>3.1.0</version>
+      <version>3.10.0</version>
     </dependency>
     <dependency>
       <groupId>org.ow2.asm</groupId>
@@ -68,31 +89,31 @@
     <dependency>
       <groupId>junit</groupId>
       <artifactId>junit</artifactId>
-      <version>4.12</version>
+      <version>4.13.1</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.truth</groupId>
       <artifactId>truth</artifactId>
-      <version>1.0</version>
+      <version>1.1</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.truth.extensions</groupId>
       <artifactId>truth-proto-extension</artifactId>
-      <version>1.0</version>
+      <version>1.1</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.truth.extensions</groupId>
       <artifactId>truth-java8-extension</artifactId>
-      <version>1.0</version>
+      <version>1.1</version>
       <scope>test</scope>
     </dependency>
     <dependency>
       <groupId>com.google.jimfs</groupId>
       <artifactId>jimfs</artifactId>
-      <version>1.0</version>
+      <version>1.2</version>
       <scope>test</scope>
     </dependency>
     <dependency>
@@ -103,8 +124,8 @@
     </dependency>
     <dependency>
       <groupId>com.google.auto.value</groupId>
-      <artifactId>auto-value</artifactId>
-      <version>1.5.3</version>
+      <artifactId>auto-value-annotations</artifactId>
+      <version>1.7.4</version>
       <scope>provided</scope>
     </dependency>
   </dependencies>
@@ -132,14 +153,38 @@
       <plugin>
         <groupId>org.apache.maven.plugins</groupId>
         <artifactId>maven-compiler-plugin</artifactId>
-        <version>3.6.2</version>
+        <version>3.8.0</version>
         <configuration>
-          <fork>true</fork>
-          <source>1.8</source>
-          <target>1.8</target>
+          <source>8</source>
+          <target>8</target>
           <encoding>UTF-8</encoding>
-          <compilerArgument>-parameters</compilerArgument>
-          <testCompilerArgument>-parameters</testCompilerArgument>
+          <fork>true</fork>
+          <compilerArgs>
+            <arg>-parameters</arg>
+            <arg>-XDcompilePolicy=simple</arg>
+            <arg>-Xplugin:ErrorProne</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED</arg>
+            <arg>-J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED</arg>
+            <arg>-J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED</arg>
+          </compilerArgs>
+          <annotationProcessorPaths>
+            <path>
+              <groupId>com.google.errorprone</groupId>
+              <artifactId>error_prone_core</artifactId>
+              <version>${errorprone.version}</version>
+            </path>
+            <path>
+              <groupId>com.google.auto.value</groupId>
+              <artifactId>auto-value</artifactId>
+              <version>1.7.4</version>
+            </path>
+          </annotationProcessorPaths>
         </configuration>
       </plugin>
       <plugin>
@@ -167,7 +212,19 @@
         <version>2.19.1</version>
         <configuration>
           <!-- set heap size to work around http://github.com/travis-ci/travis-ci/issues/3396 -->
-          <argLine>-Xmx2g</argLine>
+          <argLine>
+            -Xmx2g
+            --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
+            --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
+            --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
+            --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
+          </argLine>
         </configuration>
       </plugin>
       <plugin>
@@ -200,9 +257,33 @@
           </execution>
         </executions>
       </plugin>
-
+      <plugin>
+        <groupId>org.apache.maven.plugins</groupId>
+        <artifactId>maven-javadoc-plugin</artifactId>
+        <version>3.1.1</version>
+        <configuration>
+          <source>8</source>
+          <detectJavaApiLink>false</detectJavaApiLink>
+          <notimestamp>true</notimestamp>
+          <doctitle>turbine ${project.version} API</doctitle>
+        </configuration>
+      </plugin>
     </plugins>
   </build>
+
+  <distributionManagement>
+    <snapshotRepository>
+      <id>sonatype-nexus-snapshots</id>
+      <name>Sonatype Nexus Snapshots</name>
+      <url>https://oss.sonatype.org/content/repositories/snapshots/</url>
+    </snapshotRepository>
+    <repository>
+      <id>sonatype-nexus-staging</id>
+      <name>Nexus Release Repository</name>
+      <url>https://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
+    </repository>
+  </distributionManagement>
+
   <profiles>
     <profile>
       <id>java-8</id>
@@ -219,6 +300,66 @@
               <argLine>-Xbootclasspath/p:${settings.localRepository}/com/google/errorprone/javac/${javac.version}/javac-${javac.version}.jar</argLine>
             </configuration>
           </plugin>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-compiler-plugin</artifactId>
+            <configuration>
+              <fork>true</fork>
+              <compilerArgs>
+                <arg>-parameters</arg>
+                <arg>-XDcompilePolicy=simple</arg>
+                <arg>-Xplugin:ErrorProne</arg>
+                <arg>-J-Xbootclasspath/p:${settings.localRepository}/com/google/errorprone/javac/${javac.version}/javac-${javac.version}.jar</arg>
+              </compilerArgs>
+            </configuration>
+          </plugin>
+        </plugins>
+      </build>
+    </profile>
+    <profile>
+      <id>sonatype-oss-release</id>
+      <build>
+        <plugins>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-source-plugin</artifactId>
+            <version>${maven-source-plugin.version}</version>
+            <executions>
+              <execution>
+                <id>attach-sources</id>
+                <goals>
+                  <goal>jar-no-fork</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-javadoc-plugin</artifactId>
+            <version>${maven-javadoc-plugin.version}</version>
+            <executions>
+              <execution>
+                <id>attach-javadocs</id>
+                <goals>
+                  <goal>jar</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
+          <plugin>
+            <groupId>org.apache.maven.plugins</groupId>
+            <artifactId>maven-gpg-plugin</artifactId>
+            <version>1.6</version>
+            <executions>
+              <execution>
+                <id>sign-artifacts</id>
+                <phase>verify</phase>
+                <goals>
+                  <goal>sign</goal>
+                </goals>
+              </execution>
+            </executions>
+          </plugin>
         </plugins>
       </build>
     </profile>