aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJon Bratseth <bratseth@oath.com>2018-11-30 12:47:48 -0800
committerGitHub <noreply@github.com>2018-11-30 12:47:48 -0800
commitc0513ac34d2c438e9f97e699659855029e1f06e8 (patch)
tree8171d8c9a6414e2e16e60c1f67b1162d0bea5133
parent7bf98f903237df5e14660bdb08f8518993dc4afd (diff)
parenta9820cd241dfdaf900a3b819d0aa9afab244cf4c (diff)
Merge pull request #7829 from vespa-engine/iruotsalainen/abi-check-plugin
Add maven plugin for ABI stability checking
-rw-r--r--abi-check-plugin/README.md56
-rw-r--r--abi-check-plugin/pom.xml84
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java129
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java27
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java115
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java87
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java188
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java31
-rw-r--r--abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java22
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java48
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java28
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java44
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java12
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java92
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java89
-rw-r--r--abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java107
-rw-r--r--abi-check-plugin/src/test/java/root/Root.java5
-rw-r--r--abi-check-plugin/src/test/java/root/package-info.java4
-rw-r--r--abi-check-plugin/src/test/java/root/sub/Sub.java5
-rw-r--r--maven-plugins/pom.xml1
20 files changed, 1174 insertions, 0 deletions
diff --git a/abi-check-plugin/README.md b/abi-check-plugin/README.md
new file mode 100644
index 00000000000..23a68a94783
--- /dev/null
+++ b/abi-check-plugin/README.md
@@ -0,0 +1,56 @@
+# abi-check-plugin
+
+Maven plugin for ensuring project ABI stability.
+
+## Theory of operation
+
+The project artifact JAR is scanned for class files. The resulting package tree is scanned for
+packages annotated with configured annotation denoting a package that is considered public ABI.
+Classes in those packages are scanned for their visible ABI and the result is compared against
+expected ABI (stored in a JSON file) and possible discrepancies are reported.
+
+## What is considered visible ABI
+
+Visible ABI is considered to be classes, methods and fields that are accessible from other JAR
+files without use of reflection or other tricks.
+
+## Setup
+
+### Add plugin to build
+
+Add the plugin to `<plugins>` in the project `pom.xml`, with an execution of `abicheck` goal. This
+goal has to be executed in a phase where the project artifact JAR is available, the recommended
+phase is `package`.
+
+Example:
+```
+<plugin>
+ <groupId>com.yahoo.vespa</groupId>
+ <artifactId>abi-check-plugin</artifactId>
+ <configuration>
+ <publicApiAnnotation>com.yahoo.api.annotations.PublicApi</publicApiAnnotation>
+ </configuration>
+ <executions>
+ <execution>
+ <phase>package</phase>
+ <goals>
+ <goal>abicheck</goal>
+ </goals>
+ </execution>
+ </executions>
+</plugin>
+```
+
+### Configuration parameters
+
+ * **publicApiAnnotation** (required)
+ Fully qualified class name of the annotation that denotes a public API package.
+ * **specFileName** (optional, default: `abi-spec.json`)
+ File name relative to project root from which to read the expected ABI spec from.
+
+## Updating the expected ABI spec
+
+To automatically generate the expected ABI spec from the current ABI of the project, define
+property `abicheck.writeSpec` when running the relevant phase.
+
+Example: `mvn package -Dabicheck.writeSpec`
diff --git a/abi-check-plugin/pom.xml b/abi-check-plugin/pom.xml
new file mode 100644
index 00000000000..db7149ccdb2
--- /dev/null
+++ b/abi-check-plugin/pom.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0"?>
+<!-- Copyright 2017 Yahoo Holdings. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root. -->
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent>
+ <groupId>com.yahoo.vespa</groupId>
+ <artifactId>parent</artifactId>
+ <version>6-SNAPSHOT</version>
+ <relativePath>../parent/pom.xml</relativePath>
+ </parent>
+ <artifactId>abi-check-plugin</artifactId>
+ <version>6-SNAPSHOT</version>
+ <packaging>maven-plugin</packaging>
+ <name>${project.artifactId}</name>
+ <description>Maven Plugin for ensuring ABI stability.</description>
+ <prerequisites>
+ <maven>2.2.1</maven>
+ </prerequisites>
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-plugin-api</artifactId>
+ <version>3.5.0</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-core</artifactId>
+ <version>3.5.0</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven.plugin-tools</groupId>
+ <artifactId>maven-plugin-annotations</artifactId>
+ <scope>provided</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.ow2.asm</groupId>
+ <artifactId>asm</artifactId>
+ <version>7.0</version>
+ </dependency>
+ <dependency>
+ <groupId>com.google.guava</groupId>
+ <artifactId>guava</artifactId>
+ <version>24.0-jre</version>
+ </dependency>
+ <dependency>
+ <groupId>com.google.code.gson</groupId>
+ <artifactId>gson</artifactId>
+ <version>2.8.5</version>
+ </dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter-api</artifactId>
+ <version>5.3.1</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.hamcrest</groupId>
+ <artifactId>hamcrest-all</artifactId>
+ <version>1.3</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <version>1.10.19</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+ <build>
+ <finalName>${project.artifactId}</finalName>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-plugin-plugin</artifactId>
+ </plugin>
+ <plugin>
+ <groupId>org.openclover</groupId>
+ <artifactId>clover-maven-plugin</artifactId>
+ <version>4.3.1</version>
+ </plugin>
+ </plugins>
+ </build>
+</project>
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java
new file mode 100644
index 00000000000..c7863d85a3c
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/classtree/ClassFileTree.java
@@ -0,0 +1,129 @@
+package com.yahoo.abicheck.classtree;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayDeque;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.Deque;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+public abstract class ClassFileTree implements AutoCloseable {
+
+ public static ClassFileTree fromJar(JarFile jarFile) {
+ Map<String, Package> rootPackages = new HashMap<>();
+
+ Enumeration<JarEntry> jarEntries = jarFile.entries();
+ while (jarEntries.hasMoreElements()) {
+ JarEntry entry = jarEntries.nextElement();
+ if (!entry.isDirectory() && entry.getName().endsWith(".class")) {
+ Deque<String> parts = new ArrayDeque<>(Arrays.asList(entry.getName().split("/")));
+ String className = parts.removeLast();
+ Package pkg = rootPackages
+ .computeIfAbsent(parts.removeFirst(), name -> new Package(null, name));
+ for (String part : parts) {
+ pkg = pkg.getOrCreateSubPackage(part);
+ }
+ pkg.addClass(new ClassFile(pkg, className) {
+
+ @Override
+ public InputStream getInputStream() throws IOException {
+ return jarFile.getInputStream(entry);
+ }
+ });
+ }
+ }
+
+ return new ClassFileTree() {
+ @Override
+ public Collection<Package> getRootPackages() {
+ return rootPackages.values();
+ }
+
+ @Override
+ public void close() throws IOException {
+ jarFile.close();
+ }
+ };
+ }
+
+ public abstract void close() throws IOException;
+
+ public abstract Collection<Package> getRootPackages();
+
+ public static abstract class ClassFile {
+
+ private final Package parent;
+ private final String name;
+
+ private ClassFile(Package parent, String name) {
+ this.parent = parent;
+ this.name = name;
+ }
+
+ public abstract InputStream getInputStream() throws IOException;
+
+ public String getName() {
+ return name;
+ }
+
+ // CLOVER:OFF
+ // Testing debug methods is not necessary
+ @Override
+ public String toString() {
+ return "ClassFile(" + parent.getFullyQualifiedName() + "." + name + ")";
+ }
+ // CLOVER:ON
+ }
+
+ public static class Package {
+
+ private final Package parent;
+ private final String name;
+ private final Map<String, Package> subPackages = new HashMap<>();
+ private final Set<ClassFile> classFiles = new HashSet<>();
+
+ private Package(Package parent, String name) {
+ this.parent = parent;
+ this.name = name;
+ }
+
+ private Package getOrCreateSubPackage(String name) {
+ return subPackages.computeIfAbsent(name, n -> new Package(this, n));
+ }
+
+ private void addClass(ClassFile klazz) {
+ classFiles.add(klazz);
+ }
+
+ public String getFullyQualifiedName() {
+ if (parent == null) {
+ return name;
+ } else {
+ return parent.getFullyQualifiedName() + "." + name;
+ }
+ }
+
+ public Collection<Package> getSubPackages() {
+ return subPackages.values();
+ }
+
+ public Collection<ClassFile> getClassFiles() {
+ return classFiles;
+ }
+
+ // CLOVER:OFF
+ // Testing debug methods is not necessary
+ @Override
+ public String toString() {
+ return "Package(" + getFullyQualifiedName() + ")";
+ }
+ // CLOVER:ON
+ }
+}
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java
new file mode 100644
index 00000000000..5be66a4fbdd
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/AnnotationCollector.java
@@ -0,0 +1,27 @@
+package com.yahoo.abicheck.collector;
+
+import java.util.HashSet;
+import java.util.Set;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+public class AnnotationCollector extends ClassVisitor {
+
+ private final Set<String> annotations = new HashSet<>();
+
+ public AnnotationCollector() {
+ super(Opcodes.ASM6);
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ annotations.add(Type.getType(descriptor).getClassName());
+ return null;
+ }
+
+ public Set<String> getAnnotations() {
+ return annotations;
+ }
+}
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java
new file mode 100644
index 00000000000..d90b3c1a8dd
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/PublicSignatureCollector.java
@@ -0,0 +1,115 @@
+package com.yahoo.abicheck.collector;
+
+import com.yahoo.abicheck.signature.JavaClassSignature;
+import java.util.Arrays;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+public class PublicSignatureCollector extends ClassVisitor {
+
+ private final Map<String, JavaClassSignature> classSignatures = new LinkedHashMap<>();
+
+ private String currentName;
+ private String currentSuper;
+ private Set<String> currentInterfaces;
+ private int currentAccess;
+ private Set<String> currentMethods;
+ private Set<String> currentFields;
+
+ public PublicSignatureCollector() {
+ super(Opcodes.ASM6);
+ }
+
+ private static boolean testBit(long access, long mask) {
+ return (access & mask) != 0;
+ }
+
+ private static String describeMethod(String name, int access, String returnType,
+ List<String> argumentTypes) {
+ return String.format("%s %s %s(%s)", describeAccess(access, Util.methodFlags), returnType, name,
+ String.join(", ", argumentTypes));
+ }
+
+ private static String describeAccess(int access, List<Util.AccessFlag> possibleFlags) {
+ return String.join(" ", Util.convertAccess(access, possibleFlags));
+ }
+
+ private static String describeField(String name, int access, String type) {
+ return String.format("%s %s %s", describeAccess(access, Util.fieldFlags), type, name);
+ }
+
+ private static String internalNameToClassName(String superName) {
+ return Type.getObjectType(superName).getClassName();
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature, String superName,
+ String[] interfaces) {
+ currentName = internalNameToClassName(name);
+ currentSuper = internalNameToClassName(superName);
+ currentInterfaces = Arrays.stream(interfaces)
+ .map(PublicSignatureCollector::internalNameToClassName)
+ .collect(Collectors.toCollection(LinkedHashSet::new));
+ currentAccess = access;
+ currentMethods = new LinkedHashSet<>();
+ currentFields = new LinkedHashSet<>();
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor, String signature,
+ String[] exceptions) {
+ if (isVisibleMember(access)) {
+ Type method = Type.getMethodType(descriptor);
+ List<String> argumentTypes = Arrays.stream(method.getArgumentTypes()).map(Type::getClassName)
+ .collect(Collectors.toList());
+ currentMethods
+ .add(describeMethod(name, access, method.getReturnType().getClassName(), argumentTypes));
+ }
+ return null;
+ }
+
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor, String signature,
+ Object value) {
+ if (isVisibleMember(access)) {
+ currentFields.add(describeField(name, access, Type.getType(descriptor).getClassName()));
+ }
+ return null;
+ }
+
+ private boolean isVisibleMember(int access) {
+ // Public members are visible
+ if (testBit(access, Opcodes.ACC_PUBLIC)) {
+ return true;
+ }
+ // Protected members are visible if the class is not final (can be accessed from
+ // extending classes)
+ if (testBit(access, Opcodes.ACC_PROTECTED) && !testBit(currentAccess, Opcodes.ACC_FINAL)) {
+ return true;
+ }
+ // Otherwise not visible
+ return false;
+ }
+
+ @Override
+ public void visitEnd() {
+ if ((currentAccess & Opcodes.ACC_PUBLIC) != 0) {
+ classSignatures.put(currentName,
+ new JavaClassSignature(currentSuper, currentInterfaces,
+ Util.convertAccess(currentAccess, Util.classFlags), currentMethods, currentFields));
+ }
+ }
+
+ public Map<String, JavaClassSignature> getClassSignatures() {
+ return classSignatures;
+ }
+}
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java
new file mode 100644
index 00000000000..f308ad42db5
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/collector/Util.java
@@ -0,0 +1,87 @@
+package com.yahoo.abicheck.collector;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import org.objectweb.asm.Opcodes;
+
+public class Util {
+
+ public static final List<AccessFlag> classFlags = Arrays.asList(
+ AccessFlag.make(Opcodes.ACC_PUBLIC, "public"),
+ AccessFlag.make(Opcodes.ACC_PRIVATE, "private"),
+ AccessFlag.make(Opcodes.ACC_PROTECTED, "protected"),
+ AccessFlag.make(Opcodes.ACC_FINAL, "final"),
+ AccessFlag.ignored(Opcodes.ACC_SUPER), // Ignored, always set by modern Java
+ AccessFlag.make(Opcodes.ACC_INTERFACE, "interface"),
+ AccessFlag.make(Opcodes.ACC_ABSTRACT, "abstract"),
+ AccessFlag.make(Opcodes.ACC_SYNTHETIC, "synthetic"), // FIXME: Do we want this?
+ AccessFlag.make(Opcodes.ACC_ANNOTATION, "annotation"),
+ AccessFlag.make(Opcodes.ACC_ENUM, "enum"),
+// FIXME: Module support
+// AccessFlag.make(Opcodes.ACC_MODULE, "module")
+ AccessFlag.ignored(Opcodes.ACC_DEPRECATED)
+ );
+
+ public static final List<AccessFlag> methodFlags = Arrays.asList(
+ AccessFlag.make(Opcodes.ACC_PUBLIC, "public"),
+ AccessFlag.make(Opcodes.ACC_PRIVATE, "private"),
+ AccessFlag.make(Opcodes.ACC_PROTECTED, "protected"),
+ AccessFlag.make(Opcodes.ACC_STATIC, "static"),
+ AccessFlag.make(Opcodes.ACC_FINAL, "final"),
+ AccessFlag.make(Opcodes.ACC_SYNCHRONIZED, "synchronized"),
+ AccessFlag.make(Opcodes.ACC_BRIDGE, "bridge"),
+ AccessFlag.make(Opcodes.ACC_VARARGS, "varargs"), // FIXME: Do we want this?
+ AccessFlag.make(Opcodes.ACC_NATIVE, "native"),
+ AccessFlag.make(Opcodes.ACC_ABSTRACT, "abstract"),
+ AccessFlag.make(Opcodes.ACC_STRICT, "strict"), // FIXME: Do we want this?
+ AccessFlag.make(Opcodes.ACC_SYNTHETIC, "synthetic"), // FIXME: Do we want this?
+ AccessFlag.ignored(Opcodes.ACC_DEPRECATED)
+ );
+
+ public static final List<AccessFlag> fieldFlags = Arrays.asList(
+ AccessFlag.make(Opcodes.ACC_PUBLIC, "public"),
+ AccessFlag.make(Opcodes.ACC_PRIVATE, "private"),
+ AccessFlag.make(Opcodes.ACC_PROTECTED, "protected"),
+ AccessFlag.make(Opcodes.ACC_STATIC, "static"),
+ AccessFlag.make(Opcodes.ACC_FINAL, "final"),
+ AccessFlag.make(Opcodes.ACC_VOLATILE, "volatile"),
+ AccessFlag.make(Opcodes.ACC_TRANSIENT, "transient"),
+ AccessFlag.make(Opcodes.ACC_SYNTHETIC, "synthetic"), // FIXME: Do we want this?
+ AccessFlag.make(Opcodes.ACC_ENUM, "enum"),
+ AccessFlag.ignored(Opcodes.ACC_DEPRECATED)
+ );
+
+ public static List<String> convertAccess(int access, List<AccessFlag> flags) {
+ List<String> result = new ArrayList<>();
+ for (AccessFlag flag : flags) {
+ if ((access & flag.bit) != 0 && flag.attribute != null) {
+ result.add(flag.attribute);
+ }
+ access &= ~flag.bit;
+ }
+ if (access != 0) {
+ throw new IllegalArgumentException(String.format("Unexpected access bits: 0x%x", access));
+ }
+ return result;
+ }
+
+ public static class AccessFlag {
+
+ public final int bit;
+ public final String attribute;
+
+ private AccessFlag(int bit, String attribute) {
+ this.bit = bit;
+ this.attribute = attribute;
+ }
+
+ private static AccessFlag make(int bit, String attribute) {
+ return new AccessFlag(bit, attribute);
+ }
+
+ private static AccessFlag ignored(int bit) {
+ return new AccessFlag(bit, null);
+ }
+ }
+}
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java
new file mode 100644
index 00000000000..06d2628228c
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/mojo/AbiCheck.java
@@ -0,0 +1,188 @@
+package com.yahoo.abicheck.mojo;
+
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.reflect.TypeToken;
+import com.yahoo.abicheck.classtree.ClassFileTree;
+import com.yahoo.abicheck.classtree.ClassFileTree.ClassFile;
+import com.yahoo.abicheck.classtree.ClassFileTree.Package;
+import com.yahoo.abicheck.collector.AnnotationCollector;
+import com.yahoo.abicheck.collector.PublicSignatureCollector;
+import com.yahoo.abicheck.setmatcher.SetMatcher;
+import com.yahoo.abicheck.signature.JavaClassSignature;
+import java.io.FileReader;
+import java.io.FileWriter;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.jar.JarFile;
+import org.apache.maven.artifact.Artifact;
+import org.apache.maven.plugin.AbstractMojo;
+import org.apache.maven.plugin.MojoExecutionException;
+import org.apache.maven.plugin.MojoFailureException;
+import org.apache.maven.plugin.logging.Log;
+import org.apache.maven.plugins.annotations.LifecyclePhase;
+import org.apache.maven.plugins.annotations.Mojo;
+import org.apache.maven.plugins.annotations.Parameter;
+import org.apache.maven.plugins.annotations.ResolutionScope;
+import org.apache.maven.project.MavenProject;
+import org.objectweb.asm.ClassReader;
+
+@Mojo(
+ name = "abicheck",
+ defaultPhase = LifecyclePhase.PACKAGE,
+ requiresDependencyResolution = ResolutionScope.RUNTIME
+)
+public class AbiCheck extends AbstractMojo {
+
+ public static final String PACKAGE_INFO_CLASS_FILE_NAME = "package-info.class";
+ private static final String DEFAULT_SPEC_FILE = "abi-spec.json";
+ private static final String WRITE_SPEC_PROPERTY = "abicheck.writeSpec";
+ @Parameter(defaultValue = "${project}", readonly = true)
+ private MavenProject project = null;
+
+ @Parameter(required = true)
+ private String publicApiAnnotation = null;
+
+ @Parameter
+ private String specFileName = DEFAULT_SPEC_FILE;
+
+ // CLOVER:OFF
+ // Testing that Gson can read JSON files is not very useful
+ private static Map<String, JavaClassSignature> readSpec(String fileName) throws IOException {
+ try (FileReader reader = new FileReader(fileName)) {
+ TypeToken<Map<String, JavaClassSignature>> typeToken =
+ new TypeToken<Map<String, JavaClassSignature>>() {
+ };
+ Gson gson = new GsonBuilder().create();
+ return gson.fromJson(reader, typeToken.getType());
+ }
+ }
+ // CLOVER:ON
+
+ // CLOVER:OFF
+ // Testing that Gson can write JSON files is not very useful
+ private static void writeSpec(Map<String, JavaClassSignature> signatures, String fileName)
+ throws IOException {
+ Gson gson = new GsonBuilder().setPrettyPrinting().create();
+ try (FileWriter writer = new FileWriter(fileName)) {
+ gson.toJson(signatures, writer);
+ }
+ }
+ // CLOVER:ON
+
+ private static boolean matchingClasses(String className, JavaClassSignature expected,
+ JavaClassSignature actual, Log log) {
+ boolean match = true;
+ if (!expected.superClass.equals(actual.superClass)) {
+ match = false;
+ log.error(String
+ .format("Class %s: Expected superclass %s, found %s", className, expected.superClass,
+ actual.superClass));
+ }
+ if (!SetMatcher.compare(expected.interfaces, actual.interfaces,
+ item -> true,
+ item -> log.error(String.format("Class %s: Missing interface %s", className, item)),
+ item -> log.error(String.format("Class %s: Extra interface %s", className, item)))) {
+ match = false;
+ }
+ if (!SetMatcher
+ .compare(new HashSet<>(expected.attributes), new HashSet<>(actual.attributes),
+ item -> true,
+ item -> log.error(String.format("Class %s: Missing attribute %s", className, item)),
+ item -> log.error(String.format("Class %s: Extra attribute %s", className, item)))) {
+ match = false;
+ }
+ if (!SetMatcher.compare(expected.methods, actual.methods,
+ item -> true,
+ item -> log.error(String.format("Class %s: Missing method %s", className, item)),
+ item -> log.error(String.format("Class %s: Extra method %s", className, item)))) {
+ match = false;
+ }
+ if (!SetMatcher.compare(expected.fields, actual.fields,
+ item -> true,
+ item -> log.error(String.format("Class %s: Missing field %s", className, item)),
+ item -> log.error(String.format("Class %s: Extra field %s", className, item)))) {
+ match = false;
+ }
+ return match;
+ }
+
+ private static boolean isPublicAbiPackage(ClassFileTree.Package pkg, String publicApiAnnotation)
+ throws IOException {
+ Optional<ClassFile> pkgInfo = pkg.getClassFiles().stream()
+ .filter(klazz -> klazz.getName().equals(PACKAGE_INFO_CLASS_FILE_NAME)).findFirst();
+ if (!pkgInfo.isPresent()) {
+ return false;
+ }
+ try (InputStream is = pkgInfo.get().getInputStream()) {
+ AnnotationCollector visitor = new AnnotationCollector();
+ new ClassReader(is).accept(visitor,
+ ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ return visitor.getAnnotations().contains(publicApiAnnotation);
+ }
+ }
+
+ static Map<String, JavaClassSignature> collectPublicAbiSignatures(Package pkg,
+ String publicApiAnnotation) throws IOException {
+ Map<String, JavaClassSignature> signatures = new LinkedHashMap<>();
+ if (isPublicAbiPackage(pkg, publicApiAnnotation)) {
+ PublicSignatureCollector collector = new PublicSignatureCollector();
+ for (ClassFile klazz : pkg.getClassFiles()) {
+ try (InputStream is = klazz.getInputStream()) {
+ new ClassReader(is).accept(collector, 0);
+ }
+ }
+ signatures.putAll(collector.getClassSignatures());
+ }
+ for (ClassFileTree.Package subPkg : pkg.getSubPackages()) {
+ signatures.putAll(collectPublicAbiSignatures(subPkg, publicApiAnnotation));
+ }
+ return signatures;
+ }
+
+ static boolean compareSignatures(Map<String, JavaClassSignature> expected,
+ Map<String, JavaClassSignature> actual, Log log) {
+ return SetMatcher.compare(expected.keySet(), actual.keySet(),
+ item -> matchingClasses(item, expected.get(item), actual.get(item), log),
+ item -> log.error(String.format("Missing class: %s", item)),
+ item -> log.error(String.format("Extra class: %s", item)));
+ }
+
+ // CLOVER:OFF
+ // The main entry point is tedious to unit test
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ Artifact mainArtifact = project.getArtifact();
+ if (mainArtifact.getFile() == null) {
+ throw new MojoExecutionException("Missing project artifact file");
+ } else if (!mainArtifact.getType().equals("jar")) {
+ throw new MojoExecutionException("Project artifact is not a JAR");
+ }
+
+ getLog().debug("Analyzing " + mainArtifact.getFile());
+
+ try (JarFile jarFile = new JarFile(mainArtifact.getFile())) {
+ ClassFileTree tree = ClassFileTree.fromJar(jarFile);
+ Map<String, JavaClassSignature> signatures = new LinkedHashMap<>();
+ for (ClassFileTree.Package pkg : tree.getRootPackages()) {
+ signatures.putAll(collectPublicAbiSignatures(pkg, publicApiAnnotation));
+ }
+ if (System.getProperty(WRITE_SPEC_PROPERTY) != null) {
+ getLog().info("Writing ABI specs to " + specFileName);
+ writeSpec(signatures, specFileName);
+ } else {
+ Map<String, JavaClassSignature> abiSpec = readSpec(specFileName);
+ if (!compareSignatures(abiSpec, signatures, getLog())) {
+ throw new MojoFailureException("ABI spec mismatch");
+ }
+ }
+ } catch (IOException e) {
+ throw new MojoExecutionException("Error processing class signatures", e);
+ }
+ }
+ // CLOVER:ON
+}
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java
new file mode 100644
index 00000000000..56b08bef74f
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/setmatcher/SetMatcher.java
@@ -0,0 +1,31 @@
+package com.yahoo.abicheck.setmatcher;
+
+import com.google.common.collect.Sets;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+
+public class SetMatcher {
+
+ public static <T> boolean compare(Set<T> expected, Set<T> actual, Predicate<T> itemsMatch,
+ Consumer<T> onMissing, Consumer<T> onExtra) {
+ boolean mismatch = false;
+ Set<T> missing = Sets.difference(expected, actual);
+ for (T item : missing) {
+ mismatch = true;
+ onMissing.accept(item);
+ }
+ Set<T> extra = Sets.difference(actual, expected);
+ for (T item : extra) {
+ mismatch = true;
+ onExtra.accept(item);
+ }
+ Set<T> both = Sets.intersection(actual, expected);
+ for (T item : both) {
+ if (!itemsMatch.test(item)) {
+ mismatch = true;
+ }
+ }
+ return !mismatch;
+ }
+}
diff --git a/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java
new file mode 100644
index 00000000000..5e748d1b7e2
--- /dev/null
+++ b/abi-check-plugin/src/main/java/com/yahoo/abicheck/signature/JavaClassSignature.java
@@ -0,0 +1,22 @@
+package com.yahoo.abicheck.signature;
+
+import java.util.List;
+import java.util.Set;
+
+public class JavaClassSignature {
+
+ public final String superClass;
+ public final Set<String> interfaces;
+ public final List<String> attributes;
+ public final Set<String> methods;
+ public final Set<String> fields;
+
+ public JavaClassSignature(String superClass, Set<String> interfaces, List<String> attributes,
+ Set<String> methods, Set<String> fields) {
+ this.superClass = superClass;
+ this.interfaces = interfaces;
+ this.attributes = attributes;
+ this.methods = methods;
+ this.fields = fields;
+ }
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java
new file mode 100644
index 00000000000..eaa49789f2f
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AccessConversionTest.java
@@ -0,0 +1,48 @@
+package com.yahoo.abicheck;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import com.yahoo.abicheck.collector.Util;
+import java.util.Arrays;
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.Opcodes;
+
+public class AccessConversionTest {
+
+ @Test
+ public void testClassFlags() {
+ // ACC_SUPER should be ignored
+ assertEquals(
+ Arrays.asList("public", "abstract"),
+ Util.convertAccess(
+ Opcodes.ACC_PUBLIC | Opcodes.ACC_SUPER | Opcodes.ACC_ABSTRACT,
+ Util.classFlags));
+ }
+
+ @Test
+ public void testMethodFlags() {
+ // ACC_DEPRECATED should be ignored
+ assertEquals(
+ Arrays.asList("protected", "varargs"),
+ Util.convertAccess(
+ Opcodes.ACC_PROTECTED | Opcodes.ACC_VARARGS | Opcodes.ACC_DEPRECATED,
+ Util.methodFlags));
+ }
+
+ @Test
+ public void testFieldFlags() {
+ assertEquals(
+ Arrays.asList("private", "volatile"),
+ Util.convertAccess(
+ Opcodes.ACC_PRIVATE | Opcodes.ACC_VOLATILE,
+ Util.fieldFlags));
+ }
+
+ @Test
+ public void testUnsupportedFlags() {
+ // ACC_MODULE is not a valid flag for fields
+ assertThrows(IllegalArgumentException.class,
+ () -> Util.convertAccess(Opcodes.ACC_MODULE, Util.fieldFlags));
+ }
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java
new file mode 100644
index 00000000000..3104ce794cd
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/AnnotationCollectorTest.java
@@ -0,0 +1,28 @@
+package com.yahoo.abicheck;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+
+import com.yahoo.abicheck.collector.AnnotationCollector;
+import java.io.IOException;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.ClassReader;
+
+public class AnnotationCollectorTest {
+
+ @Test
+ public void testCollection() throws IOException {
+ ClassReader r = new ClassReader(
+ "com.yahoo.abicheck.AnnotationCollectorTest$ClassWithAnnotations");
+ AnnotationCollector collector = new AnnotationCollector();
+ r.accept(collector, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+
+ assertThat(collector.getAnnotations(), containsInAnyOrder(Disabled.class.getCanonicalName()));
+ }
+
+ @Disabled // Any RetentionPolicy.RUNTIME annotation is fine for testing
+ private static class ClassWithAnnotations {
+
+ }
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java
new file mode 100644
index 00000000000..5aa8ed2d770
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/ClassFileTreeTest.java
@@ -0,0 +1,44 @@
+package com.yahoo.abicheck;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.empty;
+import static org.hamcrest.Matchers.equalTo;
+import static org.hamcrest.Matchers.sameInstance;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.Iterables;
+import com.yahoo.abicheck.classtree.ClassFileTree;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+import org.junit.jupiter.api.Test;
+
+public class ClassFileTreeTest {
+
+ @Test
+ public void testJarParsing() throws IOException {
+ JarFile jarFile = mock(JarFile.class);
+ JarEntry classJarEntry = new JarEntry("com/yahoo/Test.class");
+ JarEntry dirJarEntry = new JarEntry("com/yahoo/");
+ InputStream jarEntryStream = mock(InputStream.class);
+ when(jarFile.entries())
+ .thenReturn(Collections.enumeration(Arrays.asList(dirJarEntry, classJarEntry)));
+ when(jarFile.getInputStream(classJarEntry)).thenReturn(jarEntryStream);
+
+ try (ClassFileTree cft = ClassFileTree.fromJar(jarFile)) {
+ ClassFileTree.Package com = Iterables.getOnlyElement(cft.getRootPackages());
+ assertThat(com.getFullyQualifiedName(), equalTo("com"));
+ assertThat(com.getClassFiles(), empty());
+ ClassFileTree.Package yahoo = Iterables.getOnlyElement(com.getSubPackages());
+ assertThat(yahoo.getFullyQualifiedName(), equalTo("com.yahoo"));
+ assertThat(yahoo.getSubPackages(), empty());
+ ClassFileTree.ClassFile testClassFile = Iterables.getOnlyElement(yahoo.getClassFiles());
+ assertThat(testClassFile.getName(), equalTo("Test.class"));
+ assertThat(testClassFile.getInputStream(), sameInstance(jarEntryStream));
+ }
+ }
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java
new file mode 100644
index 00000000000..8e7410e032d
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/Public.java
@@ -0,0 +1,12 @@
+package com.yahoo.abicheck;
+
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+@Retention(RetentionPolicy.RUNTIME)
+@Target({ElementType.PACKAGE})
+public @interface Public {
+
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java
new file mode 100644
index 00000000000..784a81de50c
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/PublicSignatureCollectorTest.java
@@ -0,0 +1,92 @@
+package com.yahoo.abicheck;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.contains;
+import static org.hamcrest.Matchers.containsInAnyOrder;
+import static org.hamcrest.Matchers.equalTo;
+
+import com.yahoo.abicheck.collector.PublicSignatureCollector;
+import com.yahoo.abicheck.signature.JavaClassSignature;
+import java.io.IOException;
+import java.util.Map;
+import java.util.function.Function;
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.ClassReader;
+
+public class PublicSignatureCollectorTest {
+
+ @Test
+ public void testCollection() throws IOException {
+ String testClassName = "com.yahoo.abicheck.PublicSignatureCollectorTest$TestClass";
+ ClassReader r = new ClassReader(testClassName);
+ PublicSignatureCollector collector = new PublicSignatureCollector();
+ r.accept(collector, 0);
+
+ Map<String, JavaClassSignature> signatures = collector.getClassSignatures();
+ assertThat(signatures.size(), equalTo(1));
+ JavaClassSignature s = signatures.get(testClassName);
+ assertThat(s.superClass, equalTo("java.lang.Object"));
+ assertThat(s.interfaces,
+ containsInAnyOrder("java.lang.Runnable", "java.util.function.Function"));
+ assertThat(s.attributes, contains("public"));
+ assertThat(s.fields, containsInAnyOrder(
+ "public static int staticPublicField",
+ "protected static int staticProtectedField",
+ "public boolean publicField",
+ "protected boolean protectedField"));
+ assertThat(s.methods, containsInAnyOrder(
+ "public void <init>()",
+ "public static java.lang.String staticPublicMethod()",
+ "protected static java.lang.String staticProtectedMethod()",
+ "public java.lang.Object publicMethod()",
+ "protected java.lang.Object protectedMethod()",
+ "public void run()",
+ "public java.lang.Void apply(java.lang.Void)",
+ "public bridge synthetic java.lang.Object apply(java.lang.Object)"
+ ));
+ }
+
+ public static class TestClass implements Runnable, Function<Void, Void> {
+
+ public static int staticPublicField = 1;
+ protected static int staticProtectedField = 2;
+ private static int staticPrivateField = 3;
+ public boolean publicField = true;
+ protected boolean protectedField = true;
+ private boolean privateField = true;
+
+ public static String staticPublicMethod() {
+ return "";
+ }
+
+ protected static String staticProtectedMethod() {
+ return "";
+ }
+
+ private static String staticPrivateMethod() {
+ return "";
+ }
+
+ public Object publicMethod() {
+ return null;
+ }
+
+ protected Object protectedMethod() {
+ return null;
+ }
+
+ private Object privateMethod() {
+ return null;
+ }
+
+ @Override
+ public void run() {
+
+ }
+
+ @Override
+ public Void apply(Void aVoid) {
+ return null;
+ }
+ }
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java
new file mode 100644
index 00000000000..37755147fb2
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/SetMatcherTest.java
@@ -0,0 +1,89 @@
+package com.yahoo.abicheck;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Matchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableSet;
+import com.yahoo.abicheck.setmatcher.SetMatcher;
+import java.util.Collections;
+import java.util.Set;
+import java.util.function.Consumer;
+import java.util.function.Predicate;
+import org.junit.jupiter.api.Test;
+
+public class SetMatcherTest {
+
+ @Test
+ public void testMissing() {
+ Set<String> a = ImmutableSet.of("a", "b");
+
+ @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class);
+
+ when(itemsMatch.test("a")).thenReturn(true);
+
+ assertThat(SetMatcher.compare(a, Collections.singleton("a"), itemsMatch, missing, extra),
+ equalTo(false));
+
+ verify(missing, times(1)).accept("b");
+ verify(extra, never()).accept(any());
+ verify(itemsMatch, times(1)).test("a");
+ }
+
+ @Test
+ public void testExtra() {
+ Set<String> a = ImmutableSet.of("a");
+
+ @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class);
+
+ when(itemsMatch.test("a")).thenReturn(true);
+
+ assertThat(SetMatcher.compare(a, ImmutableSet.of("a", "b"), itemsMatch, missing, extra),
+ equalTo(false));
+
+ verify(missing, never()).accept(any());
+ verify(extra, times(1)).accept("b");
+ verify(itemsMatch, times(1)).test("a");
+ }
+
+ @Test
+ public void testItemsMatch() {
+ @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class);
+
+ when(itemsMatch.test("a")).thenReturn(false);
+
+ assertThat(SetMatcher
+ .compare(Collections.singleton("a"), Collections.singleton("a"), itemsMatch, missing,
+ extra), equalTo(false));
+
+ verify(itemsMatch, times(1)).test("a");
+ }
+
+ @Test
+ public void testCompleteMatch() {
+ @SuppressWarnings("unchecked") Consumer<String> missing = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Consumer<String> extra = mock(Consumer.class);
+ @SuppressWarnings("unchecked") Predicate<String> itemsMatch = mock(Predicate.class);
+
+ when(itemsMatch.test("a")).thenReturn(true);
+
+ assertThat(SetMatcher
+ .compare(Collections.singleton("a"), Collections.singleton("a"), itemsMatch, missing,
+ extra), equalTo(true));
+
+ verify(missing, never()).accept(any());
+ verify(extra, never()).accept(any());
+ verify(itemsMatch, times(1)).test("a");
+ }
+}
diff --git a/abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java b/abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java
new file mode 100644
index 00000000000..4de6f186800
--- /dev/null
+++ b/abi-check-plugin/src/test/java/com/yahoo/abicheck/mojo/AbiCheckTest.java
@@ -0,0 +1,107 @@
+package com.yahoo.abicheck.mojo;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.Matchers.equalTo;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.google.common.collect.ImmutableMap;
+import com.yahoo.abicheck.Public;
+import com.yahoo.abicheck.classtree.ClassFileTree;
+import com.yahoo.abicheck.signature.JavaClassSignature;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Map;
+import org.apache.maven.plugin.logging.Log;
+import org.junit.jupiter.api.Test;
+import root.Root;
+import root.sub.Sub;
+
+public class AbiCheckTest {
+
+ private static ClassFileTree.Package buildClassFileTree() throws IOException {
+ ClassFileTree.Package rootPkg = mock(ClassFileTree.Package.class);
+ ClassFileTree.Package subPkg = mock(ClassFileTree.Package.class);
+
+ ClassFileTree.ClassFile rootPkgInfoClass = mock(ClassFileTree.ClassFile.class);
+ ClassFileTree.ClassFile rootPkgClass = mock(ClassFileTree.ClassFile.class);
+ ClassFileTree.ClassFile subPkgClass = mock(ClassFileTree.ClassFile.class);
+
+ when(rootPkg.getSubPackages()).thenReturn(Collections.singleton(subPkg));
+ when(rootPkg.getClassFiles()).thenReturn(Arrays.asList(rootPkgClass, rootPkgInfoClass));
+ when(subPkg.getClassFiles()).thenReturn(Collections.singleton(subPkgClass));
+
+ when(rootPkgInfoClass.getName()).thenReturn("package-info.class");
+ when(rootPkgInfoClass.getInputStream())
+ .thenAnswer(invocation -> Root.class.getResourceAsStream("package-info.class"));
+
+ when(rootPkgClass.getName()).thenReturn("Root.class");
+ when(rootPkgClass.getInputStream())
+ .thenAnswer(invocation -> Root.class.getResourceAsStream("Root.class"));
+
+ when(subPkgClass.getName()).thenReturn("Sub.class");
+ when(subPkgClass.getInputStream())
+ .thenAnswer(invocation -> Sub.class.getResourceAsStream("Sub.class"));
+
+ return rootPkg;
+ }
+
+ @Test
+ public void testCollectPublicAbiSignatures() throws IOException {
+ ClassFileTree.Package rootPkg = buildClassFileTree();
+
+ Map<String, JavaClassSignature> signatures = AbiCheck
+ .collectPublicAbiSignatures(rootPkg, Public.class.getCanonicalName());
+
+ assertThat(signatures.size(), equalTo(1));
+ JavaClassSignature rootSignature = signatures.get("root.Root");
+
+ // PublicSignatureCollectorTest verifies actual signatures, no need to duplicate here
+ }
+
+ @Test
+ public void testCompareSignatures() {
+ Log log = mock(Log.class);
+
+ JavaClassSignature signatureA = new JavaClassSignature(
+ "java.lang.Object",
+ Collections.emptySet(),
+ Collections.singletonList("public"),
+ Collections.singleton("public void foo()"),
+ Collections.singleton("public int bar"));
+ JavaClassSignature signatureB = new JavaClassSignature(
+ "java.lang.Exception",
+ Collections.singleton("java.lang.Runnable"),
+ Collections.singletonList("protected"),
+ Collections.singleton("public void foo(int)"),
+ Collections.singleton("public boolean bar"));
+
+ Map<String, JavaClassSignature> expected = ImmutableMap.<String, JavaClassSignature>builder()
+ .put("test.Missing", signatureA)
+ .put("test.A", signatureA)
+ .put("test.B", signatureB)
+ .build();
+
+ Map<String, JavaClassSignature> actual = ImmutableMap.<String, JavaClassSignature>builder()
+ .put("test.A", signatureA)
+ .put("test.Extra", signatureA)
+ .put("test.B", signatureA)
+ .build();
+
+ assertThat(AbiCheck.compareSignatures(expected, actual, log), equalTo(false));
+
+ verify(log).error("Missing class: test.Missing");
+ verify(log).error("Extra class: test.Extra");
+ verify(log)
+ .error("Class test.B: Expected superclass java.lang.Exception, found java.lang.Object");
+ verify(log).error("Class test.B: Missing interface java.lang.Runnable");
+ verify(log).error("Class test.B: Missing attribute protected");
+ verify(log).error("Class test.B: Extra attribute public");
+ verify(log).error("Class test.B: Missing method public void foo(int)");
+ verify(log).error("Class test.B: Extra method public void foo()");
+ verify(log).error("Class test.B: Missing field public boolean bar");
+ verify(log).error("Class test.B: Extra field public int bar");
+ }
+}
diff --git a/abi-check-plugin/src/test/java/root/Root.java b/abi-check-plugin/src/test/java/root/Root.java
new file mode 100644
index 00000000000..58fd210159f
--- /dev/null
+++ b/abi-check-plugin/src/test/java/root/Root.java
@@ -0,0 +1,5 @@
+package root;
+
+public class Root {
+
+}
diff --git a/abi-check-plugin/src/test/java/root/package-info.java b/abi-check-plugin/src/test/java/root/package-info.java
new file mode 100644
index 00000000000..26f0f5c5a6f
--- /dev/null
+++ b/abi-check-plugin/src/test/java/root/package-info.java
@@ -0,0 +1,4 @@
+@Public
+package root;
+
+import com.yahoo.abicheck.Public; \ No newline at end of file
diff --git a/abi-check-plugin/src/test/java/root/sub/Sub.java b/abi-check-plugin/src/test/java/root/sub/Sub.java
new file mode 100644
index 00000000000..c4f330644e9
--- /dev/null
+++ b/abi-check-plugin/src/test/java/root/sub/Sub.java
@@ -0,0 +1,5 @@
+package root.sub;
+
+public class Sub {
+
+}
diff --git a/maven-plugins/pom.xml b/maven-plugins/pom.xml
index 546dfdafcd9..be03926499f 100644
--- a/maven-plugins/pom.xml
+++ b/maven-plugins/pom.xml
@@ -23,5 +23,6 @@
<module>../config-class-plugin</module>
<module>../application-deploy-plugin</module>
<module>../vespa-application-maven-plugin</module>
+ <module>../abi-check-plugin</module>
</modules>
</project>