diff --git a/api-checker/README.md b/api-checker/README.md
new file mode 100644
index 0000000000000..7db9851dde31f
--- /dev/null
+++ b/api-checker/README.md
@@ -0,0 +1,90 @@
+# Kafka API-checker plugins
+
+This is a separate Gradle build (composite-included from the root `settings.gradle` via
+`pluginManagement { includeBuild 'api-checker' }`) that produces the KIP-1265 API checkers.
+Living outside Kafka's main build keeps the Maven runtime dependencies off the main-build
+classpath and lets each published artifact carry only the classes its consumers need.
+
+| Subproject | Publishes | Audience |
+|---|---|---|
+| `:core` | `org.apache.kafka:kafka-api-checker-core` | Shared scanner + validator + reporter (ASM only). Both plugin jars depend on it. |
+| `:gradle-plugins` | `org.apache.kafka:kafka-internal-api-checker-gradle-plugin` + plugin markers for `org.apache.kafka.public-api-checker` and `org.apache.kafka.internal-api-checker` | The Kafka-internal producer-side checker and the published consumer-side Gradle checker. |
+| `:maven-plugin` | `org.apache.kafka:kafka-internal-api-checker-maven-plugin` | Maven equivalent of the consumer-side checker. |
+
+End-user documentation (Gradle/Maven snippets, `@SuppressKafkaInternalApiUsage`,
+audience-inheritance rules) lives at
+[`docs/apis/internal-api-checker.md`](../docs/apis/internal-api-checker.md). The notes
+below cover building, testing, and publishing the plugins themselves.
+
+## Build
+
+```bash
+./gradlew :api-checker:core:build :api-checker:gradle-plugins:build :api-checker:maven-plugin:build
+```
+
+## Test
+
+```bash
+./gradlew :api-checker:core:test :api-checker:gradle-plugins:test
+```
+
+`:gradle-plugins:test` includes a Gradle TestKit end-to-end test that applies the
+`org.apache.kafka.internal-api-checker` plugin to a synthetic consumer project.
+
+## Publish
+
+Each subproject's `publish` task stages to the URL passed via `-PmavenUrl` (with
+`-PmavenUsername` / `-PmavenPassword` for credentials). The version is read from the
+repo-root `gradle.properties`, so `release.py`'s existing `updateVersion` call sets it
+automatically. `-PkafkaPluginsVersion=…` overrides for one-off out-of-band publishes.
+
+```bash
+# Stage to ASF Nexus alongside the rest of an AK release
+./gradlew :api-checker:core:publish \
+ :api-checker:gradle-plugins:publish \
+ :api-checker:maven-plugin:publish \
+ -PmavenUrl=$ASF_NEXUS_STAGING_URL \
+ -PmavenUsername=$NEXUS_USER \
+ -PmavenPassword=$NEXUS_PASS
+
+# Local smoke-test
+./gradlew :api-checker:core:publishToMavenLocal \
+ :api-checker:gradle-plugins:publishToMavenLocal \
+ :api-checker:maven-plugin:publishToMavenLocal
+```
+
+The five published coordinates:
+
+- `org.apache.kafka:kafka-api-checker-core:$KAFKA_VERSION` — shared scanner library.
+- `org.apache.kafka:kafka-internal-api-checker-gradle-plugin:$KAFKA_VERSION` — Gradle plugin
+ implementation jar (consumed by the marker poms).
+- `org.apache.kafka.internal-api-checker:org.apache.kafka.internal-api-checker.gradle.plugin:$KAFKA_VERSION` —
+ marker pom for `plugins { id 'org.apache.kafka.internal-api-checker' }`.
+- `org.apache.kafka.public-api-checker:org.apache.kafka.public-api-checker.gradle.plugin:$KAFKA_VERSION` —
+ marker pom for the producer-side checker. (Kafka-internal use, but published from the
+ same module for consistency.)
+- `org.apache.kafka:kafka-internal-api-checker-maven-plugin:$KAFKA_VERSION` — Maven plugin
+ (packaging `maven-plugin`).
+
+## Layout
+
+```
+api-checker/
+├── settings.gradle # declares the three subprojects
+├── build.gradle # group / version / signing / common publishing config
+├── core/
+│ ├── build.gradle # ASM dep; published as kafka-api-checker-core
+│ └── src/
+│ ├── main/java/.../apicheck/ # scanner, validators, reporter
+│ ├── test/java/.../apicheck/ # unit tests
+│ └── testFixtures/java/.../apicheck/ # AsmClassFactory, TempJarBuilder
+│ # — re-used by :gradle-plugins' tests
+├── gradle-plugins/
+│ ├── build.gradle # java-gradle-plugin; depends on :core
+│ └── src/{main,test}/java/.../gradle/ # Plugin/Task/Extension × 2
+└── maven-plugin/
+ ├── build.gradle # Maven deps; templates plugin.xml at processResources
+ └── src/main/
+ ├── java/.../maven/KafkaInternalApiCheckerMojo.java
+ └── resources/META-INF/maven/plugin.xml
+```
diff --git a/api-checker/build.gradle b/api-checker/build.gradle
new file mode 100644
index 0000000000000..b5e54456809ba
--- /dev/null
+++ b/api-checker/build.gradle
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+// Shared configuration for the api-checker included build. Lives outside Kafka's main
+// settings tree so its Maven dependencies (in :maven-plugin) don't end up on every
+// developer's main-build classpath, and so each published artifact carries only the classes
+// its consumers need.
+
+// Default the published version to the same string as the rest of Kafka (gradle.properties
+// at the repo root). release.py's `updateVersion` task writes that file, so the included
+// build's artifacts ship under the AK release version automatically. `-PkafkaPluginsVersion=…`
+// overrides for one-off out-of-band publishes.
+def rootGradleProps = new Properties()
+def rootGradlePropsFile = file('../gradle.properties')
+if (rootGradlePropsFile.exists()) {
+ rootGradlePropsFile.withInputStream { rootGradleProps.load(it) }
+}
+def kafkaVersion = providers.gradleProperty('kafkaPluginsVersion')
+ .orElse(providers.provider { rootGradleProps.getProperty('version') })
+ .orElse('1.0.0-SNAPSHOT')
+
+allprojects {
+ group = 'org.apache.kafka'
+ version = kafkaVersion.get()
+
+ repositories {
+ gradlePluginPortal()
+ mavenCentral()
+ }
+}
+
+subprojects {
+ apply plugin: 'java-library'
+ apply plugin: 'maven-publish'
+ apply plugin: 'signing'
+ apply plugin: 'checkstyle'
+
+ java {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ // Maven Central requires sources + javadoc jars alongside every artifact.
+ withSourcesJar()
+ withJavadocJar()
+ }
+
+ test {
+ useJUnitPlatform()
+ }
+
+ checkstyle {
+ toolVersion = '12.3.1'
+ configDirectory = file("$rootDir/../checkstyle")
+ configProperties = [importControlFile: 'import-control-api-checker.xml']
+ }
+
+ publishing {
+ repositories {
+ // Mirrors the root Kafka project's publishing.repositories block — credentials
+ // come from ~/.gradle/gradle.properties via the same -PmavenUrl / -PmavenUsername /
+ // -PmavenPassword properties release.py uses.
+ maven {
+ url = project.hasProperty('mavenUrl') ? project.mavenUrl : ''
+ credentials {
+ username = project.hasProperty('mavenUsername') ? project.mavenUsername : ''
+ password = project.hasProperty('mavenPassword') ? project.mavenPassword : ''
+ }
+ }
+ }
+ }
+
+ def skipSigning = project.hasProperty('skipSigning') && skipSigning.toBoolean()
+ def shouldSign = !skipSigning && !version.toString().endsWith('SNAPSHOT')
+ if (shouldSign) {
+ signing {
+ sign publishing.publications
+ }
+ }
+}
diff --git a/api-checker/core/build.gradle b/api-checker/core/build.gradle
new file mode 100644
index 0000000000000..dbf0b678db98b
--- /dev/null
+++ b/api-checker/core/build.gradle
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+// Shared scanner + validator + reporter for the KIP-1265 API checkers. Both the Gradle
+// plugin and the Maven plugin depend on this module — neither carries the bytecode
+// scanner classes themselves any more, so the published Gradle jar no longer drags Maven
+// classes (and vice versa).
+
+apply plugin: 'java-test-fixtures'
+
+dependencies {
+ // ASM is the entire production dependency footprint. No Gradle, no Maven.
+ //
+ // ASM versions cap the JDK class-file major version they can parse. 9.6 covers up to
+ // Java 21 (class-file 65). If Kafka starts compiling against a newer JDK whose major
+ // exceeds what this ASM release knows about, the scanner will throw
+ // `IllegalArgumentException: Unsupported class file major version N` on encountering
+ // a fresh bytecode file. Bump in lockstep with the project's max-supported compile JDK.
+ api 'org.ow2.asm:asm:9.6'
+ api 'org.slf4j:slf4j-api:2.0.16'
+
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
+ testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
+ testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2'
+
+ // ASM-based class-file builders used by every checker test in the included build.
+ // Exposed as test-fixtures so the gradle-plugins module's tests can pull them in via
+ // `testImplementation(testFixtures(project(':core')))` without us re-publishing them
+ // as a separate "tests" classifier artifact.
+ testFixturesImplementation 'org.ow2.asm:asm:9.6'
+}
+
+publishing {
+ publications {
+ coreJar(MavenPublication) {
+ artifactId = 'kafka-api-checker-core'
+ from components.java
+
+ pom {
+ name = 'Apache Kafka API Checker Core'
+ description = 'Bytecode scanner and reporter shared by the KIP-1265 Gradle and Maven plugins'
+ url = 'https://kafka.apache.org'
+
+ licenses {
+ license {
+ name = 'The Apache License, Version 2.0'
+ url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
+ }
+ }
+ developers {
+ developer {
+ id = 'apache-kafka'
+ name = 'Apache Kafka Team'
+ email = 'dev@kafka.apache.org'
+ }
+ }
+ scm {
+ connection = 'scm:git:https://github.com/apache/kafka.git'
+ developerConnection = 'scm:git:https://github.com/apache/kafka.git'
+ url = 'https://github.com/apache/kafka'
+ }
+ }
+ }
+ }
+}
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurface.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurface.java
new file mode 100644
index 0000000000000..b5d1dab3d7bf5
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurface.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+import java.util.function.Predicate;
+
+/**
+ * The Kafka public-API surface, resolved from one project-jars scan. Immutable; consumed by the
+ * cascade and javadoc validators. Built via {@link ApiSurfaceScanner}.
+ *
+ *
All lookup methods accept a class name in either binary ({@code Outer$Inner}) or dotted
+ * ({@code Outer.Inner}) form — callers don't need to know which form the surface stores. The two
+ * iteration sets ({@link #effectivePublic()} and {@link #directPublic()}) return {@link ClassFacts}
+ * directly so callers never juggle name strings.
+ */
+final class ApiSurface {
+
+ /** Every class that is effectively {@code @Public} (direct or inherited), regardless of visibility. */
+ private final Set effectivePublic;
+ /** Classes carrying a direct {@code @InterfaceAudience.Public} — drives the MISSING_JAVADOC iteration. */
+ private final Set directPublic;
+ private final Map byDottedName;
+ private final Map jarByDottedName;
+
+ private ApiSurface(Builder b) {
+ this.effectivePublic = Set.copyOf(b.effectivePublic);
+ this.directPublic = Set.copyOf(b.directPublic);
+ this.byDottedName = Map.copyOf(b.byDottedName);
+ this.jarByDottedName = Map.copyOf(b.jarByDottedName);
+ }
+
+ /**
+ * Every class that is effectively {@code @Public} (direct or inherited). Cascade iteration
+ * filters this further on {@link ClassFacts#isExternallyVisible()} — private/package-private
+ * nested classes inherit the audience but their methods aren't reachable to consumers.
+ */
+ Set effectivePublic() {
+ return effectivePublic;
+ }
+
+ /** Classes carrying a *direct* {@code @InterfaceAudience.Public}. Drives the MISSING_JAVADOC check. */
+ Set directPublic() {
+ return directPublic;
+ }
+
+ /** Look up facts by either binary or dotted name. Returns {@code null} if not in any scanned jar. */
+ ClassFacts factsOf(String name) {
+ return byDottedName.get(normalize(name));
+ }
+
+ /** @return the jar that contained the class, or {@code null} if not in any scanned jar. */
+ File jarOf(String name) {
+ return jarByDottedName.get(normalize(name));
+ }
+
+ /**
+ * True iff the class is effectively {@code @Public} — directly or via enclosing-class
+ * inheritance, regardless of source-level access. Walks the enclosing chain just like
+ * {@link #isDeprecated}; explicit {@code @InterfaceAudience.Private} on a nested class
+ * overrides an inherited {@code @Public}.
+ */
+ boolean isEffectivelyPublic(String name) {
+ ClassFacts hit = findInChain(name, f -> f.isPrivate() || f.isPublic());
+ return hit != null && hit.isPublic();
+ }
+
+ /**
+ * True iff the class — or any enclosing class — carries {@code @Deprecated}. Deprecation
+ * propagates through nesting so a nested class of a {@code @Deprecated} outer is itself
+ * out of scope on both validation sides (mirrors the {@code @Public} inheritance model).
+ */
+ boolean isDeprecated(String name) {
+ return findInChain(name, ClassFacts::isDeprecated) != null;
+ }
+
+ /**
+ * Walk the enclosing chain and return the first {@link ClassFacts} for which {@code stopAt}
+ * is true, or {@code null} if nothing matches before we reach the top-level class.
+ *
+ * When the current level resolves to {@link ClassFacts}, stepping uses
+ * {@link ClassFacts#enclosingName} (works for both binary and dotted-form input). When it
+ * doesn't — e.g. an anonymous intermediate like {@code Outer$1} that
+ * {@link ApiSurfaceScanner#isSyntheticOrAnonymous} dropped — we fall back to lexical
+ * {@code $}-stripping so the chain continues past the gap. That fallback matches the
+ * scanner's own {@code resolveEffectiveAudience} so the two walks agree on what counts as
+ * an effective audience.
+ */
+ private ClassFacts findInChain(String name, Predicate stopAt) {
+ String current = name;
+ while (current != null) {
+ ClassFacts facts = factsOf(current);
+ if (facts != null) {
+ if (stopAt.test(facts)) return facts;
+ current = facts.enclosingName();
+ } else {
+ String parent = ClassFacts.parentBinaryName(current);
+ if (parent == null) return null;
+ current = parent;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Treat {@code $} purely as a Java-style nesting separator. This is correct for Java
+ * source-level nested classes and for the {@code @InterfaceAudience.Public} surface (which
+ * is itself defined in plain Java). It's a known oversimplification for Scala/Kotlin
+ * compiled output — e.g. Scala companion-object names ({@code Foo$}) or anonymous-function
+ * synthetics ({@code Foo$$anonfun$1}) — but those compiler-generated symbols are not part
+ * of the Public surface, so any normalization confusion they cause stays below the
+ * checker's prefix gate.
+ */
+ private static String normalize(String name) {
+ return name.indexOf('$') < 0 ? name : name.replace('$', '.');
+ }
+
+ static Builder builder() {
+ return new Builder();
+ }
+
+ /** Accumulator used by {@link ApiSurfaceScanner}; {@link #build()} freezes into an {@link ApiSurface}. */
+ static final class Builder {
+ private final Set effectivePublic = new HashSet<>();
+ private final Set directPublic = new HashSet<>();
+ private final Map byDottedName = new HashMap<>();
+ private final Map jarByDottedName = new HashMap<>();
+
+ /** Record a class's facts and the jar it came from. First jar wins for duplicates. */
+ Builder recordClass(ClassFacts facts, File jar) {
+ byDottedName.put(facts.dottedName(), facts);
+ jarByDottedName.putIfAbsent(facts.dottedName(), jar);
+ return this;
+ }
+
+ /** Add a class that is effectively {@code @Public} (direct or inherited), any visibility. */
+ Builder addEffectivePublic(ClassFacts facts) {
+ effectivePublic.add(facts);
+ return this;
+ }
+
+ Builder addDirectPublic(ClassFacts facts) {
+ directPublic.add(facts);
+ return this;
+ }
+
+ ApiSurface build() {
+ return new ApiSurface(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurfaceScanner.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurfaceScanner.java
new file mode 100644
index 0000000000000..5a991c51a9419
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ApiSurfaceScanner.java
@@ -0,0 +1,225 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.Enumeration;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Builds an {@link ApiSurface} from a set of project jars in a single two-pass scan:
+ *
+ * - Read each class's direct bytecode facts (annotations, source-level access).
+ * - Walk every class's enclosing-class chain (Hadoop-style inheritance) to resolve effective
+ * audience and assemble the derived dotted-name sets in the surface.
+ *
+ *
+ * Reads bytecode metadata via ASM rather than the classloader, so a class with broken
+ * transitive deps (gRPC stubs, telemetry shims, …) doesn't trip {@code LinkageError} —
+ * annotation descriptors live in the constant pool, no linking required.
+ */
+final class ApiSurfaceScanner {
+
+ // Bytecode descriptors used to identify class-level annotations the checker cares about.
+ private static final String PUBLIC_API_DESCRIPTOR =
+ "Lorg/apache/kafka/common/annotation/InterfaceAudience$Public;";
+ private static final String PRIVATE_API_DESCRIPTOR =
+ "Lorg/apache/kafka/common/annotation/InterfaceAudience$Private;";
+ private static final String DEPRECATED_DESCRIPTOR = "Ljava/lang/Deprecated;";
+
+ private ApiSurfaceScanner() {}
+
+ /**
+ * Scan the project's own jars and any reference jars (sibling Kafka modules it depends on)
+ * and return an immutable surface.
+ *
+ *
Reference-jar classes contribute to {@link ApiSurface#factsOf} lookups and the
+ * membership set behind {@link ApiSurface#isEffectivelyPublic} so cross-module
+ * {@code @Public} references resolve correctly, but they do NOT take part in this
+ * project's MISSING_JAVADOC iteration or cascade iteration — each module checks its
+ * own surface.
+ */
+ static ApiSurface scan(List projectJars, List referenceJars) throws IOException {
+ ApiSurface.Builder surface = ApiSurface.builder();
+ Map byBinaryName = new HashMap<>();
+ Set projectJarDottedNames = new HashSet<>();
+
+ // Pass 1 — read facts for every in-scope class. Project-owned classes get tracked so
+ // pass 2 can keep them out of reference-only iteration sets.
+ scanJars(projectJars, byBinaryName, surface, projectJarDottedNames::add);
+ scanJars(referenceJars, byBinaryName, surface, name -> { });
+
+ // Pass 2 — resolve inheritance and populate the surface's derived sets. Deprecated
+ // classes are out of scope on both validation sides; the surface answers
+ // {@link ApiSurface#isDeprecated} directly from the per-class facts so no separate
+ // deprecated set is needed here.
+ for (ClassFacts facts : byBinaryName.values()) {
+ if (facts.isDeprecated() || !projectJarDottedNames.contains(facts.dottedName())) continue;
+ if (facts.isPublic()) {
+ surface.addDirectPublic(facts);
+ }
+ if (resolveEffectiveAudience(facts.binaryName(), byBinaryName) == DirectAudience.PUBLIC) {
+ // Owned effective-Public classes all go into the surface — cross-module
+ // @Public references resolve via {@link ApiSurface#isEffectivelyPublic} which
+ // walks the enclosing chain. Reference-jar classes don't need to be added
+ // here: they're validated by their own module's task.
+ //
+ // Iteration consumers (CascadeValidator) filter on
+ // {@link ClassFacts#isExternallyVisible()} at the call site — private nested
+ // classes inherit the audience but their methods/ctors aren't reachable to
+ // consumers and shouldn't be cascade-walked.
+ surface.addEffectivePublic(facts);
+ }
+ }
+
+ return surface.build();
+ }
+
+ /** Backwards-compatible overload for callers that don't need reference jars (consumer-side scan). */
+ static ApiSurface scan(List projectJars) throws IOException {
+ return scan(projectJars, java.util.Collections.emptyList());
+ }
+
+ private static void scanJars(List jars, Map byBinaryName,
+ ApiSurface.Builder surface,
+ java.util.function.Consumer markOwned) throws IOException {
+ for (File jar : jars) {
+ try (JarFile jarFile = new JarFile(jar)) {
+ Enumeration entries = jarFile.entries();
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+ if (!entry.getName().endsWith(".class")) continue;
+
+ String binaryName = entry.getName()
+ .replace('/', '.')
+ .replaceAll(".class$", "");
+ if (binaryName.endsWith("package-info") || binaryName.endsWith("module-info")) continue;
+ if (!binaryName.startsWith("org.apache.kafka.")) continue;
+ // Anonymous / local / synthetic classes are never part of the public API surface,
+ // but would otherwise inherit @Public from an enclosing class under the
+ // Hadoop-style inheritance rule and trip cascade checks.
+ if (isSyntheticOrAnonymous(binaryName)) continue;
+ // First jar wins for cross-jar duplicates — project jars are scanned first
+ // so they keep ownership over a class also present in a reference jar.
+ if (byBinaryName.containsKey(binaryName)) continue;
+
+ ClassFacts facts = readClassFacts(jarFile, entry, binaryName);
+ byBinaryName.put(binaryName, facts);
+ surface.recordClass(facts, jar);
+ markOwned.accept(facts.dottedName());
+ }
+ }
+ }
+ }
+
+ /** Read a class file's bytecode facts via ASM (jar-entry variant). */
+ static ClassFacts readClassFacts(JarFile jar, JarEntry entry, String binaryName) throws IOException {
+ try (InputStream in = jar.getInputStream(entry)) {
+ return readClassFactsFromStream(in, binaryName);
+ }
+ }
+
+ /** Read a class file's bytecode facts via ASM (stream variant — used for classpath lookups). */
+ static ClassFacts readClassFactsFromStream(InputStream in, String binaryName) throws IOException {
+ ClassFacts.Builder builder = ClassFacts.builder(binaryName);
+ String internalName = binaryName.replace('.', '/');
+ ClassReader reader = new ClassReader(in);
+ reader.accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public void visit(int version, int access, String name, String signature,
+ String superName, String[] interfaces) {
+ builder.sourceAccess(access); // top-level access; overridden below for nested
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ if (PUBLIC_API_DESCRIPTOR.equals(descriptor)) {
+ builder.addFlag(ClassFacts.Flag.PUBLIC_API);
+ } else if (PRIVATE_API_DESCRIPTOR.equals(descriptor)) {
+ builder.addFlag(ClassFacts.Flag.PRIVATE_API);
+ } else if (DEPRECATED_DESCRIPTOR.equals(descriptor)) {
+ builder.addFlag(ClassFacts.Flag.DEPRECATED);
+ }
+ return null;
+ }
+
+ @Override
+ public void visitInnerClass(String name, String outerName, String innerName, int access) {
+ if (internalName.equals(name)) {
+ // For nested classes the InnerClasses entry holds the real source-level
+ // access; the class header's ACC_PUBLIC is a compiler artefact.
+ builder.sourceAccess(access);
+ }
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ return builder.build();
+ }
+
+ /**
+ * @return true if the binary name encodes an anonymous, local, or compiler-synthetic class
+ * (e.g. {@code Outer$1}, {@code Outer$1$Inner}, {@code Outer$$Lambda$N}). Such classes
+ * are never part of the public API surface.
+ */
+ private static boolean isSyntheticOrAnonymous(String binaryName) {
+ if (binaryName.contains("$$")) return true; // lambdas / synthetic accessor classes
+ int dollar = binaryName.indexOf('$');
+ while (dollar >= 0) {
+ int nextDollar = binaryName.indexOf('$', dollar + 1);
+ int end = nextDollar < 0 ? binaryName.length() : nextDollar;
+ // A segment that starts with a digit is an anonymous or local class.
+ if (end > dollar + 1 && Character.isDigit(binaryName.charAt(dollar + 1))) {
+ return true;
+ }
+ dollar = nextDollar;
+ }
+ return false;
+ }
+
+ /**
+ * Walk the enclosing-class chain (by stripping {@code $}-segments from the binary name) and
+ * return the audience of the nearest class with an explicit annotation. Default is
+ * {@code Private} per the KIP. Uses the same {@link ClassFacts#parentBinaryName} stepping
+ * rule as {@link ApiSurface#findInChain} so the two walks agree on missing intermediates.
+ */
+ private static DirectAudience resolveEffectiveAudience(String binaryName, Map byBinaryName) {
+ String name = binaryName;
+ while (name != null) {
+ ClassFacts facts = byBinaryName.get(name);
+ if (facts != null) {
+ if (facts.isPublic()) return DirectAudience.PUBLIC;
+ if (facts.isPrivate()) return DirectAudience.PRIVATE;
+ }
+ name = ClassFacts.parentBinaryName(name);
+ }
+ return DirectAudience.PRIVATE;
+ }
+
+ private enum DirectAudience { PUBLIC, PRIVATE }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java
new file mode 100644
index 0000000000000..6282f895cc2b8
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java
@@ -0,0 +1,372 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+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;
+import org.objectweb.asm.signature.SignatureReader;
+import org.objectweb.asm.signature.SignatureVisitor;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Checks that no public method of any effectively-{@code @Public} class leaks an internal Kafka
+ * type through its return type, parameter types, or declared exceptions. Each finding either
+ * lands in {@link CheckResult#violations()} (a real failure) or in
+ * {@link CheckResult#suppressions()} (silenced by a class- or method-level
+ * {@code @SuppressKafkaInternalApiUsage} — the reason is captured so reviewers can audit every
+ * escape hatch on every build).
+ *
+ * Reads bytecode directly via ASM rather than reflecting on a loaded {@code Class>}, which
+ * sidesteps {@code LinkageError} / {@code NoClassDefFoundError} from broken transitive deps
+ * (gRPC stubs, telemetry shims, etc.). The same robustness property as {@link ApiSurfaceScanner}.
+ */
+final class CascadeValidator {
+
+ /** {@code @SuppressKafkaInternalApiUsage} — the escape hatch for known cascade leaks pending review. */
+ private static final String SUPPRESS_DESCRIPTOR =
+ "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+
+ private CascadeValidator() {}
+
+ static CheckResult validate(ApiSurface surface) throws IOException {
+ List violations = new ArrayList<>();
+ List suppressions = new ArrayList<>();
+ // Group by jar so each archive is opened once. Private/package-private nested classes
+ // inherit the audience but their methods and ctors aren't reachable to consumers, so
+ // cascade-walking them would just produce noise on internal helpers — filter them out
+ // before grouping.
+ Map> classesByJar = new LinkedHashMap<>();
+ for (ClassFacts cls : surface.effectivePublic()) {
+ if (!cls.isExternallyVisible()) continue;
+ File jar = surface.jarOf(cls.binaryName());
+ if (jar == null) continue;
+ classesByJar.computeIfAbsent(jar, j -> new ArrayList<>()).add(cls);
+ }
+ for (Map.Entry> e : classesByJar.entrySet()) {
+ try (JarFile jar = new JarFile(e.getKey())) {
+ for (ClassFacts cls : e.getValue()) {
+ checkClass(cls, jar, surface, violations, suppressions);
+ }
+ }
+ }
+ return new CheckResult(violations, suppressions);
+ }
+
+ private static void checkClass(ClassFacts cls, JarFile jar, ApiSurface surface,
+ List violations,
+ List suppressions) throws IOException {
+ String entryPath = cls.binaryName().replace('.', '/') + ".class";
+ JarEntry entry = jar.getJarEntry(entryPath);
+ if (entry == null) return;
+ try (InputStream in = jar.getInputStream(entry)) {
+ ClassReader reader = new ClassReader(in);
+ reader.accept(new CascadeClassVisitor(cls, surface, violations, suppressions),
+ ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+ }
+
+ /**
+ * Drives the cascade check for a single class: validates the class header (extends/implements
+ * + generic supertype args), then dispatches to per-method and per-field visitors that buffer
+ * findings so a member-level {@code @SuppressKafkaInternalApiUsage} can divert them after the
+ * fact.
+ */
+ private static final class CascadeClassVisitor extends ClassVisitor {
+ private final ClassFacts cls;
+ private final ApiSurface surface;
+ private final List violations;
+ private final List suppressions;
+ /** Reason from a class-level {@code @SuppressKafkaInternalApiUsage}, or null. */
+ private String classSuppressionReason;
+ /**
+ * Buffer extends/implements violations until {@link #visitEnd} so the
+ * class-level {@code @SuppressKafkaInternalApiUsage} (visited after the class
+ * header in ASM's event order) can divert them to suppressions if present.
+ */
+ private final List headerBuffered = new ArrayList<>();
+
+ CascadeClassVisitor(ClassFacts cls, ApiSurface surface,
+ List violations,
+ List suppressions) {
+ super(Opcodes.ASM9);
+ this.cls = cls;
+ this.surface = surface;
+ this.violations = violations;
+ this.suppressions = suppressions;
+ }
+
+ @Override
+ public void visit(int version, int access, String name, String signature,
+ String superName, String[] interfaces) {
+ // The class header itself leaks a non-public type if the @Public class
+ // extends or implements an internal Kafka type the consumer can name.
+ if (superName != null && !"java/lang/Object".equals(superName)) {
+ checkSupertype(superName.replace('/', '.'),
+ "Public class extends non-public API type");
+ }
+ if (interfaces != null) {
+ for (String iface : interfaces) {
+ checkSupertype(iface.replace('/', '.'),
+ "Public class implements non-public API type");
+ }
+ }
+ // Generic supertype + interface type arguments live in the signature.
+ // Walk via the supertype-aware path so package-private intermediates are
+ // skipped just like the direct extends/implements check above.
+ if (signature != null) {
+ new SignatureReader(signature).accept(new SignatureVisitor(Opcodes.ASM9) {
+ @Override
+ public void visitClassType(String typeName) {
+ checkSupertype(typeName.replace('/', '.'),
+ "Public class header signature exposes non-public API type");
+ }
+ });
+ }
+ }
+
+ /**
+ * Supertype cascade is stricter than the method/field cascade: package-private
+ * supertypes aren't a real leak because a consumer can't even name
+ * them from outside the package (no {@code instanceof}, no downcast). Skip
+ * those; otherwise fall through to the same cascade rule as everywhere else.
+ */
+ private void checkSupertype(String binaryName, String message) {
+ ClassFacts target = surface.factsOf(binaryName);
+ if (target != null && !target.isExternallyVisible()) return;
+ checkBinaryReference(binaryName, "INVALID_SUPERTYPE", message,
+ cls.binaryName(), null, surface, headerBuffered);
+ }
+
+ @Override
+ public void visitEnd() {
+ if (classSuppressionReason != null) {
+ for (PublicApiViolation original : headerBuffered) {
+ suppressions.add(asSuppression(original, classSuppressionReason));
+ }
+ } else {
+ violations.addAll(headerBuffered);
+ }
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ if (SUPPRESS_DESCRIPTOR.equals(descriptor)) {
+ return new ReasonCaptureVisitor(r -> classSuppressionReason = r);
+ }
+ return null;
+ }
+
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor,
+ String signature, String[] exceptions) {
+ // KIP-1265: a Public class's externally-visible methods (public + protected,
+ // since protected members are reachable to subclasses of an extensible Public
+ // class) must not leak non-public types.
+ if ((access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) == 0) return null;
+ // Bridge/synthetic methods are compiler-generated and never source-level API.
+ if ((access & (Opcodes.ACC_BRIDGE | Opcodes.ACC_SYNTHETIC)) != 0) return null;
+
+ // Buffer would-be violations and route them in visitEnd, because the method's
+ // own @SuppressKafkaInternalApiUsage is visited *after* visitMethod returns.
+ List buffered = new ArrayList<>();
+ checkAsmType(Type.getReturnType(descriptor), "INVALID_RETURN_TYPE",
+ "Public method returns non-public API type",
+ cls.binaryName(), name, surface, buffered);
+ for (Type argType : Type.getArgumentTypes(descriptor)) {
+ checkAsmType(argType, "INVALID_PARAMETER_TYPE",
+ "Public method has non-public API parameter type",
+ cls.binaryName(), name, surface, buffered);
+ }
+ if (exceptions != null) {
+ for (String excInternal : exceptions) {
+ checkBinaryReference(excInternal.replace('/', '.'),
+ "INVALID_EXCEPTION_TYPE",
+ "Public method declares non-public API exception type",
+ cls.binaryName(), name, surface, buffered);
+ }
+ }
+ // Generic type arguments (e.g. Map) live in the
+ // signature, not the erased descriptor — walk them too so the cascade
+ // catches leaks the type-erasure layer would otherwise hide.
+ collectSignatureRefs(signature, "INVALID_PARAMETER_TYPE",
+ "Public method signature exposes non-public API type",
+ cls.binaryName(), name, surface, buffered);
+
+ return new BufferedMemberVisitor(buffered);
+ }
+
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor,
+ String signature, Object value) {
+ // KIP-1265 names field types explicitly: a Public class's externally-visible
+ // fields (public + protected) must not expose non-public types either.
+ if ((access & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) == 0) return null;
+ if ((access & Opcodes.ACC_SYNTHETIC) != 0) return null;
+
+ // Buffer the would-be violation and route it in visitEnd, because the
+ // field's own @SuppressKafkaInternalApiUsage is visited *after* visitField.
+ List buffered = new ArrayList<>();
+ checkAsmType(Type.getType(descriptor), "INVALID_FIELD_TYPE",
+ "Public field exposes non-public API type",
+ cls.binaryName(), name, surface, buffered);
+ // Walk the generic field signature too — `List` etc. is
+ // erased to plain List in the descriptor.
+ collectSignatureRefs(signature, "INVALID_FIELD_TYPE",
+ "Public field signature exposes non-public API type",
+ cls.binaryName(), name, surface, buffered);
+
+ return new BufferedFieldVisitor(buffered);
+ }
+
+ /**
+ * Drains {@code buffered} into either {@link #violations} or {@link #suppressions} after
+ * the member's own {@code @SuppressKafkaInternalApiUsage} (if any) has been seen. Falls
+ * back to the class-level suppression reason when the member doesn't carry its own.
+ */
+ private void flush(List buffered, String memberReason) {
+ String reason = memberReason != null ? memberReason : classSuppressionReason;
+ if (reason != null) {
+ for (PublicApiViolation original : buffered) {
+ suppressions.add(asSuppression(original, reason));
+ }
+ } else {
+ violations.addAll(buffered);
+ }
+ }
+
+ /** MethodVisitor that captures a method-level suppression reason and flushes on visitEnd. */
+ private final class BufferedMemberVisitor extends MethodVisitor {
+ private final List buffered;
+ private String reason;
+
+ BufferedMemberVisitor(List buffered) {
+ super(Opcodes.ASM9);
+ this.buffered = buffered;
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(String d, boolean v) {
+ if (SUPPRESS_DESCRIPTOR.equals(d)) {
+ return new ReasonCaptureVisitor(r -> reason = r);
+ }
+ return null;
+ }
+
+ @Override
+ public void visitEnd() {
+ flush(buffered, reason);
+ }
+ }
+
+ /** FieldVisitor that captures a field-level suppression reason and flushes on visitEnd. */
+ private final class BufferedFieldVisitor extends FieldVisitor {
+ private final List buffered;
+ private String reason;
+
+ BufferedFieldVisitor(List buffered) {
+ super(Opcodes.ASM9);
+ this.buffered = buffered;
+ }
+
+ @Override
+ public AnnotationVisitor visitAnnotation(String d, boolean v) {
+ if (SUPPRESS_DESCRIPTOR.equals(d)) {
+ return new ReasonCaptureVisitor(r -> reason = r);
+ }
+ return null;
+ }
+
+ @Override
+ public void visitEnd() {
+ flush(buffered, reason);
+ }
+ }
+ }
+
+ /**
+ * Walk a generic JVM signature and route each referenced class type through the cascade
+ * check. {@code signature} is the optional generic descriptor ASM hands to
+ * {@code visitMethod}/{@code visitField}; it's null for non-generic members.
+ */
+ private static void collectSignatureRefs(String signature, String violationType, String message,
+ String owner, String memberName, ApiSurface surface,
+ List violations) {
+ if (signature == null) return;
+ new SignatureReader(signature).accept(new SignatureVisitor(Opcodes.ASM9) {
+ @Override
+ public void visitClassType(String name) {
+ checkBinaryReference(name.replace('/', '.'),
+ violationType, message, owner, memberName, surface, violations);
+ }
+ });
+ }
+
+ /** Recurse through array element types to find the concrete reference type, then check it. */
+ private static void checkAsmType(Type type, String violationType, String message,
+ String owner, String methodName, ApiSurface surface,
+ List violations) {
+ if (type.getSort() == Type.ARRAY) {
+ checkAsmType(type.getElementType(), violationType, message, owner, methodName, surface, violations);
+ } else if (type.getSort() == Type.OBJECT) {
+ // Type.getClassName() returns the binary form (e.g. "org.apache.kafka.X$Y").
+ checkBinaryReference(type.getClassName(), violationType, message, owner, methodName, surface, violations);
+ }
+ }
+
+ /**
+ * Apply the cascade rule to one referenced type. The reference is a violation iff it is
+ * in {@code org.apache.kafka.*}, not deprecated, and not in the surface's
+ * effective-Public-dotted set.
+ */
+ private static void checkBinaryReference(String binaryName, String violationType, String message,
+ String owner, String methodName, ApiSurface surface,
+ List violations) {
+ if (!binaryName.startsWith("org.apache.kafka.")) return;
+ if (surface.isDeprecated(binaryName)) return;
+ if (surface.isEffectivelyPublic(binaryName)) return;
+ violations.add(new PublicApiViolation(owner, violationType, message + ": " + binaryName, methodName));
+ }
+
+ /**
+ * Render a would-be violation as a suppression entry that the reporter prints in the
+ * "Suppressions" section. The reason from the {@code @SuppressKafkaInternalApiUsage}
+ * annotation is appended so reviewers can audit every escape hatch on every build.
+ */
+ private static PublicApiViolation asSuppression(PublicApiViolation original, String reason) {
+ String prettyReason = reason.isEmpty() ? PublicApiViolation.NO_REASON_MARKER : reason;
+ String description = "Suppressed " + original.getViolationType() + " in "
+ + original.getClassName() + "#" + original.getMemberName()
+ + " — " + original.getDescription()
+ + " — reason: " + prettyReason;
+ return new PublicApiViolation(original.getClassName(), "SUPPRESSED", description, original.getMemberName());
+ }
+
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/CheckResult.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CheckResult.java
new file mode 100644
index 0000000000000..9625461fb095a
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CheckResult.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import java.util.List;
+
+/**
+ * Outcome of any of the KIP-1265 checks: the Kafka-internal cascade and javadoc validators
+ * (returning would-be public-API leaks against Kafka's own surface) and the consumer-side
+ * internal-usage scanner (returning references from a downstream project's bytecode to a
+ * non-{@code @Public} Kafka type).
+ *
+ * Violations are real failures that should fail the build. Suppressions are would-be
+ * violations that were silenced by a class-, method-, or field-level
+ * {@code @SuppressKafkaInternalApiUsage}; each carries the reason supplied to the annotation
+ * so reviewers can audit every escape hatch on every build.
+ */
+public final class CheckResult {
+ private final List violations;
+ private final List suppressions;
+
+ public CheckResult(List violations, List suppressions) {
+ this.violations = violations == null ? List.of() : List.copyOf(violations);
+ this.suppressions = suppressions == null ? List.of() : List.copyOf(suppressions);
+ }
+
+ public List violations() {
+ return violations;
+ }
+
+ public List suppressions() {
+ return suppressions;
+ }
+
+ public boolean hasViolations() {
+ return !violations.isEmpty();
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ClassFacts.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ClassFacts.java
new file mode 100644
index 0000000000000..2ba45922ce7bf
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ClassFacts.java
@@ -0,0 +1,162 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.objectweb.asm.Opcodes;
+
+import java.util.EnumSet;
+
+/**
+ * Everything the checker needs to know about a single class, read once via ASM and cached.
+ * Carries:
+ *
+ * - The class's two names — binary ({@code org.apache.kafka.X$Y}) for jar-entry lookup,
+ * dotted ({@code org.apache.kafka.X.Y}) for javadoc HTML comparison.
+ * - The set of class-level bytecode annotations the checker recognises (see {@link Flag}).
+ * - The class's source-level access flag — for top-level classes the compiler writes the
+ * true source access on the class header; for nested classes the header is always
+ * {@code ACC_PUBLIC} regardless of source, but the real access lives in the
+ * {@code InnerClasses} attribute entry for the class itself. The scanner reads both, with
+ * the inner-class entry winning when present.
+ *
+ *
+ * Reading from bytecode rather than loading the class sidesteps {@code LinkageError} /
+ * {@code NoClassDefFoundError} from broken transitive deps (gRPC stubs, telemetry shims, etc.) —
+ * annotation descriptors live in the class file's constant pool, no linking required.
+ *
+ *
Instances are immutable; construct via {@link #builder(String)} so the ASM visitor that
+ * populates each field has an explicit mutable target instead of poking the result type.
+ * Equality is by {@code binaryName} so instances can be deduplicated in sets.
+ */
+final class ClassFacts {
+
+ /** Class-level bytecode annotation markers the checker cares about. */
+ enum Flag {
+ PUBLIC_API,
+ PRIVATE_API,
+ DEPRECATED
+ }
+
+ private final String binaryName;
+ private final String dottedName;
+ private final EnumSet flags;
+ private final int sourceAccess;
+
+ private ClassFacts(Builder b) {
+ this.binaryName = b.binaryName;
+ this.dottedName = b.binaryName.replace('$', '.');
+ this.flags = b.flags.isEmpty() ? EnumSet.noneOf(Flag.class) : EnumSet.copyOf(b.flags);
+ this.sourceAccess = b.sourceAccess;
+ }
+
+ String binaryName() {
+ return binaryName;
+ }
+
+ String dottedName() {
+ return dottedName;
+ }
+
+ /** Carries a direct {@code @InterfaceAudience.Public}. */
+ boolean isPublic() {
+ return flags.contains(Flag.PUBLIC_API);
+ }
+
+ /** Carries a direct {@code @InterfaceAudience.Private} — overrides inherited Public on a nested class. */
+ boolean isPrivate() {
+ return flags.contains(Flag.PRIVATE_API);
+ }
+
+ /** Carries {@code @Deprecated} at the class level. */
+ boolean isDeprecated() {
+ return flags.contains(Flag.DEPRECATED);
+ }
+
+ /** True iff source-level access is {@code public} or {@code protected}. */
+ boolean isExternallyVisible() {
+ return (sourceAccess & (Opcodes.ACC_PUBLIC | Opcodes.ACC_PROTECTED)) != 0;
+ }
+
+ /**
+ * @return dotted name of the enclosing class, or {@code null} if this is a top-level class.
+ * Lets chain walks avoid manipulating the {@code $} separator directly.
+ */
+ String enclosingName() {
+ String parent = parentBinaryName(binaryName);
+ return parent == null ? null : parent.replace('$', '.');
+ }
+
+ /**
+ * @return the immediate-parent binary name of a nested class (strip the trailing
+ * {@code $segment}), or {@code null} for top-level classes. The single canonical
+ * way to step one level outward in the enclosing-class chain.
+ */
+ static String parentBinaryName(String binaryName) {
+ int dollar = binaryName.lastIndexOf('$');
+ return dollar < 0 ? null : binaryName.substring(0, dollar);
+ }
+
+ /**
+ * @return the outermost compilation-unit binary name — strip everything from the first
+ * {@code $} onward. Used for self-reference detection (two binary names share the
+ * same outermost iff they're nested under the same top-level class).
+ */
+ static String outermostBinaryName(String binaryName) {
+ int dollar = binaryName.indexOf('$');
+ return dollar < 0 ? binaryName : binaryName.substring(0, dollar);
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (!(o instanceof ClassFacts)) return false;
+ return binaryName.equals(((ClassFacts) o).binaryName);
+ }
+
+ @Override
+ public int hashCode() {
+ return binaryName.hashCode();
+ }
+
+ static Builder builder(String binaryName) {
+ return new Builder(binaryName);
+ }
+
+ /** Mutable accumulator used by the ASM visitor; {@link #build()} freezes into a {@link ClassFacts}. */
+ static final class Builder {
+ private final String binaryName;
+ private final EnumSet flags = EnumSet.noneOf(Flag.class);
+ private int sourceAccess;
+
+ private Builder(String binaryName) {
+ this.binaryName = binaryName;
+ }
+
+ Builder addFlag(Flag flag) {
+ flags.add(flag);
+ return this;
+ }
+
+ Builder sourceAccess(int access) {
+ this.sourceAccess = access;
+ return this;
+ }
+
+ ClassFacts build() {
+ return new ClassFacts(this);
+ }
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.java
new file mode 100644
index 0000000000000..ece264e2860ea
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.java
@@ -0,0 +1,113 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Cross-validates the published javadoc HTML against the project's bytecode-level audience
+ * annotations. Two complementary checks:
+ *
+ * - {@code MISSING_JAVADOC} — a class carries a *direct* {@code @InterfaceAudience.Public}
+ * but has no HTML page in the javadoc jar. Fired only on direct annotation because
+ * javadoc doesn't emit a separate page for inherited-Public nested classes (protected or
+ * package-private); their docs live on the outer's page.
+ * - {@code MISSING_PUBLICAPI_ANNOTATION} — a class appears in the javadoc HTML but isn't
+ * effectively {@code @Public} (direct or inherited).
+ *
+ *
+ * Deprecated classes are out of scope on both sides: the deprecation set on the
+ * {@link ApiSurface} is subtracted from the HTML-discovered set before cross-validation.
+ *
+ *
Returns a {@link CheckResult} with an empty suppressions list — these violations don't
+ * currently have an escape-hatch mechanism, but the uniform shape lets callers compose
+ * validators without per-validator special cases.
+ */
+final class JavadocConsistencyValidator {
+
+ private JavadocConsistencyValidator() {}
+
+ static CheckResult validate(File javadocJar, ApiSurface surface) throws IOException {
+ List violations = new ArrayList<>();
+
+ Set classesWithPublicDoc = findClassesFromJavadocHtml(javadocJar);
+ classesWithPublicDoc.removeIf(surface::isDeprecated);
+
+ for (ClassFacts facts : surface.directPublic()) {
+ if (!classesWithPublicDoc.contains(facts.dottedName())) {
+ violations.add(new PublicApiViolation(
+ facts.dottedName(),
+ "MISSING_JAVADOC",
+ "Class has @InterfaceAudience.Public annotation but is missing from javadoc",
+ null));
+ }
+ }
+
+ for (String dottedName : classesWithPublicDoc) {
+ if (!surface.isEffectivelyPublic(dottedName)) {
+ violations.add(new PublicApiViolation(
+ dottedName,
+ "MISSING_PUBLICAPI_ANNOTATION",
+ "Class appears in javadoc but lacks @InterfaceAudience.Public annotation",
+ null));
+ }
+ }
+
+ return new CheckResult(violations, List.of());
+ }
+
+ /** Find Kafka-namespaced class names from HTML files in a javadoc JAR. */
+ private static Set findClassesFromJavadocHtml(File javadocJar) throws IOException {
+ Set classes = new HashSet<>();
+ try (JarFile jar = new JarFile(javadocJar)) {
+ Enumeration entries = jar.entries();
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+ if (!isClassHtmlFile(entry.getName())) continue;
+ String className = convertHtmlPathToClassName(entry.getName());
+ if (className.startsWith("org.apache.kafka.")) {
+ classes.add(className);
+ }
+ }
+ }
+ return classes;
+ }
+
+ /** True if the entry path is an {@code org/apache/kafka/...} class HTML page. */
+ private static boolean isClassHtmlFile(String path) {
+ if (!path.endsWith(".html")) return false;
+ if (!path.startsWith("org/apache/kafka/")) return false;
+ String fileName = path.substring(path.lastIndexOf('/') + 1);
+ // Class HTML files start with an uppercase letter; structural pages (index, overview-tree,
+ // package-summary, …) don't.
+ String classNamePart = fileName.replaceAll(".html$", "");
+ return !classNamePart.isEmpty() && Character.isUpperCase(classNamePart.charAt(0));
+ }
+
+ /** {@code org/apache/kafka/common/resource/Resource.html} → {@code org.apache.kafka.common.resource.Resource}. */
+ private static String convertHtmlPathToClassName(String htmlPath) {
+ return htmlPath.replace('/', '.').replaceAll(".html$", "");
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java
new file mode 100644
index 0000000000000..0bf38949c4bbe
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java
@@ -0,0 +1,615 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Handle;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+import org.objectweb.asm.signature.SignatureReader;
+import org.objectweb.asm.signature.SignatureVisitor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.BufferedInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Enumeration;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Predicate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarFile;
+
+/**
+ * Scans compiled JVM bytecode (.class files, packaged or loose) for references to Kafka classes
+ * that are not annotated with {@code @InterfaceAudience.Public}. Catches Java, Scala, Kotlin and
+ * any other JVM-language consumer uniformly — unlike a source-level scan, which is regex-bound
+ * to .java imports.
+ *
+ * Known limitations
+ * A handful of bytecode reference kinds are intentionally not walked. None of these are likely
+ * in practice for plugin/connector code but they're worth knowing about when interpreting a
+ * "0 violations" report:
+ *
+ * - Parameter annotations ({@code MethodVisitor#visitParameterAnnotation}) — the
+ * annotation TYPE on a parameter, e.g. {@code void foo(@InternalAnno String s)}. The
+ * method's own header (return / parameter / exception types) is walked, so any usage
+ * that survives erasure into the method descriptor is still caught.
+ * - Type-use annotations (JSR 308, {@code MethodVisitor#visitTypeAnnotation}) —
+ * annotations attached to type positions like {@code List<@InternalAnno String>}. Used
+ * almost exclusively by static-analysis tools; the underlying type is still recorded.
+ * - Class literals inside annotation element values — e.g.
+ * {@code @SomeAnnotation(impl = InternalClass.class)}. The annotation type itself is
+ * recorded but {@code AnnotationVisitor#visit} isn't traversed into for class-typed
+ * values. {@code InternalClass.class} loaded into a local also shows up via
+ * {@code visitLdcInsn} from the method body, so a typical "save it then return it"
+ * pattern is still caught — the gap is only when the class literal exists exclusively
+ * inside an annotation.
+ * - Inlined compile-time constants — Java inlines {@code public static final}
+ * primitive/String constants at the use site, so {@code InternalClass.SOME_CONSTANT}
+ * leaves no reference in the consumer's bytecode at all. This is documented in the KIP.
+ *
+ */
+public class PluginDeveloperApiUsageScanner {
+ private static final Logger LOG = LoggerFactory.getLogger(PluginDeveloperApiUsageScanner.class);
+ private static final int ASM_API = Opcodes.ASM9;
+
+ /** Internal-form prefix (slashes) for any class we care about checking the audience of. */
+ private static final String KAFKA_INTERNAL_PREFIX = "org/apache/kafka/";
+ /** Descriptor of {@code @SuppressKafkaInternalApiUsage} — honoured when present on the enclosing class or member. */
+ private static final String SUPPRESS_DESCRIPTOR =
+ "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+ private static final String NO_REASON_GIVEN = PublicApiViolation.NO_REASON_MARKER;
+
+ private final Predicate isPublicApi;
+
+ /**
+ * @param isPublicApi callback that returns {@code true} when the given binary class name
+ * (e.g. {@code org.apache.kafka.clients.producer.KafkaProducer}) is part
+ * of the public API surface
+ */
+ public PluginDeveloperApiUsageScanner(Predicate isPublicApi) {
+ this.isPublicApi = isPublicApi;
+ }
+
+ /**
+ * Scan every {@code .class} entry reachable from the supplied roots. Each root may be a
+ * directory of class files, an individual .class file, or a .jar archive.
+ */
+ public CheckResult scan(List roots) throws IOException {
+ // Use maps keyed by (consumer class, referenced internal class, member, line) so we
+ // don't double-record the same call site reachable through multiple visitor callbacks.
+ Map violations = new LinkedHashMap<>();
+ Map suppressions = new LinkedHashMap<>();
+ for (File root : roots) {
+ if (root == null || !root.exists()) {
+ continue;
+ }
+ if (root.isDirectory()) {
+ scanDirectory(root, violations, suppressions);
+ } else if (root.getName().endsWith(".jar")) {
+ scanJar(root, violations, suppressions);
+ } else if (root.getName().endsWith(".class")) {
+ try (InputStream in = new BufferedInputStream(Files.newInputStream(root.toPath()))) {
+ scanClassStream(in, violations, suppressions);
+ }
+ }
+ }
+ return new CheckResult(new ArrayList<>(violations.values()),
+ new ArrayList<>(suppressions.values()));
+ }
+
+ private void scanDirectory(File dir,
+ Map violations,
+ Map suppressions) throws IOException {
+ File[] children = dir.listFiles();
+ if (children == null) {
+ return;
+ }
+ for (File child : children) {
+ if (child.isDirectory()) {
+ scanDirectory(child, violations, suppressions);
+ } else if (child.getName().endsWith(".class")) {
+ try (InputStream in = new BufferedInputStream(Files.newInputStream(child.toPath()))) {
+ scanClassStream(in, violations, suppressions);
+ }
+ }
+ }
+ }
+
+ private void scanJar(File jar,
+ Map violations,
+ Map suppressions) throws IOException {
+ try (JarFile jarFile = new JarFile(jar)) {
+ Enumeration entries = jarFile.entries();
+ while (entries.hasMoreElements()) {
+ JarEntry entry = entries.nextElement();
+ if (!entry.getName().endsWith(".class")) {
+ continue;
+ }
+ try (InputStream in = new BufferedInputStream(jarFile.getInputStream(entry))) {
+ scanClassStream(in, violations, suppressions);
+ }
+ }
+ }
+ }
+
+ private void scanClassStream(InputStream in,
+ Map violations,
+ Map suppressions) throws IOException {
+ ClassReader reader = new ClassReader(in);
+ reader.accept(new ReferenceCollectingClassVisitor(violations, suppressions), ClassReader.SKIP_FRAMES);
+ }
+
+ private void recordIfInternal(String internalName,
+ String consumerClass,
+ String memberName,
+ int line,
+ String suppressionReason,
+ Map violations,
+ Map suppressions) {
+ String binaryName = resolveInternalKafkaReference(internalName);
+ if (binaryName == null) {
+ return;
+ }
+ // Test the full nested name. The predicate (backed by ApiSurface#isEffectivelyPublic)
+ // walks the enclosing chain level-by-level, so an explicit @InterfaceAudience.Private
+ // on a nested class correctly overrides an inherited @Public from its outer — collapsing
+ // to the outer name here would silently allow exactly that override.
+ if (isPublicApi.test(binaryName)) {
+ return;
+ }
+ // Don't report a class flagging references to its own outermost compilation unit
+ // (covers self-references and references between siblings nested under the same outer).
+ if (ClassFacts.outermostBinaryName(binaryName).equals(ClassFacts.outermostBinaryName(consumerClass))) {
+ return;
+ }
+ String location = formatLocation(consumerClass, memberName, line);
+ String key = referenceKey(consumerClass, binaryName, memberName, line);
+ if (suppressionReason != null) {
+ recordSuppression(suppressions, key, binaryName, memberName, location, suppressionReason);
+ } else {
+ recordViolation(violations, key, binaryName, memberName, location);
+ }
+ }
+
+ /**
+ * @return the dotted binary name of an {@code org.apache.kafka.*} type referenced by
+ * {@code internalName} (an ASM internal name or descriptor), or {@code null} if the
+ * reference is to a non-Kafka type, a primitive, or unparseable.
+ */
+ private static String resolveInternalKafkaReference(String internalName) {
+ if (internalName == null) {
+ return null;
+ }
+ String trimmed = stripDescriptor(internalName);
+ if (trimmed == null || !trimmed.startsWith(KAFKA_INTERNAL_PREFIX)) {
+ return null;
+ }
+ return trimmed.replace('/', '.');
+ }
+
+ /** Render the consumer-side location as {@code Class#member (line N)}, omitting absent parts. */
+ private static String formatLocation(String consumerClass, String memberName, int line) {
+ StringBuilder sb = new StringBuilder(consumerClass);
+ if (memberName != null) {
+ sb.append('#').append(memberName);
+ }
+ if (line > 0) {
+ sb.append(" (line ").append(line).append(')');
+ }
+ return sb.toString();
+ }
+
+ /** Stable de-dup key so the same call-site reported via multiple visitor callbacks collapses to one entry. */
+ private static String referenceKey(String consumerClass, String binaryName, String memberName, int line) {
+ return consumerClass + "|" + binaryName + "|" + (memberName == null ? "" : memberName) + "|" + line;
+ }
+
+ private static void recordSuppression(Map suppressions,
+ String key, String binaryName, String memberName,
+ String location, String reason) {
+ String prettyReason = reason.isEmpty() ? NO_REASON_GIVEN : reason;
+ LOG.info("Suppressed internal-API reference to {} from {}: {}", binaryName, location, prettyReason);
+ String description = String.format(
+ "Suppressed reference to internal Kafka class %s from %s — reason: %s",
+ binaryName, location, prettyReason);
+ suppressions.putIfAbsent(key,
+ new PublicApiViolation(binaryName, "SUPPRESSED_INTERNAL_API_USAGE", description, memberName));
+ }
+
+ private static void recordViolation(Map violations,
+ String key, String binaryName, String memberName, String location) {
+ String description = String.format(
+ "Bytecode reference to internal Kafka class %s from %s",
+ binaryName, location);
+ violations.putIfAbsent(key,
+ new PublicApiViolation(binaryName, "INTERNAL_API_USAGE", description, memberName));
+ }
+
+ /** Convert any of: {@code Lorg/apache/kafka/Foo;}, {@code [Lorg/apache/kafka/Foo;}, {@code org/apache/kafka/Foo} to the bare internal form. */
+ /**
+ * Return the ASM internal name for a reference-typed {@link Type} (OBJECT or ARRAY), or
+ * {@code null} for VOID/primitive types. {@link Type#getInternalName()} is only documented
+ * for reference types — calling it on a primitive happens to produce a single-char descriptor
+ * ("V"/"I"/...) today which the prefix gate happens to reject, but that's undefined behaviour
+ * we shouldn't depend on.
+ */
+ private static String referenceInternalName(Type type) {
+ int sort = type.getSort();
+ return (sort == Type.OBJECT || sort == Type.ARRAY) ? type.getInternalName() : null;
+ }
+
+ private static String stripDescriptor(String name) {
+ if (name == null || name.isEmpty()) {
+ return null;
+ }
+ int i = 0;
+ while (i < name.length() && name.charAt(i) == '[') {
+ i++;
+ }
+ if (i >= name.length()) {
+ return null;
+ }
+ char c = name.charAt(i);
+ if (c == 'L' && name.endsWith(";")) {
+ return name.substring(i + 1, name.length() - 1);
+ }
+ // Primitive descriptor (I, J, Z, ...) — nothing internal to record.
+ if (i > 0 || "VZBSCIJFD".indexOf(c) >= 0) {
+ return null;
+ }
+ return name;
+ }
+
+ /**
+ * Buffered reference. Header references (class super/interfaces, method return/param types,
+ * field type) are buffered because the {@code @SuppressKafkaInternalApiUsage} annotation that
+ * may legitimise them is visited after the header. Body-instruction references can be
+ * recorded immediately because annotations on a method/field are visited before the body.
+ */
+ private static final class PendingReference {
+ final String internalName;
+ final String memberName;
+ final int line;
+ PendingReference(String internalName, String memberName, int line) {
+ this.internalName = internalName;
+ this.memberName = memberName;
+ this.line = line;
+ }
+ }
+
+ /** Visits a class and records every referenced type, honouring {@code @SuppressKafkaInternalApiUsage}. */
+ private final class ReferenceCollectingClassVisitor extends ClassVisitor {
+ private final Map violations;
+ private final Map suppressions;
+ private String currentClass;
+ private String classSuppression; // null = none; otherwise the reason (may be empty string)
+ private final List headerRefs = new ArrayList<>();
+
+ ReferenceCollectingClassVisitor(Map violations,
+ Map suppressions) {
+ super(ASM_API);
+ this.violations = violations;
+ this.suppressions = suppressions;
+ }
+
+ /**
+ * Class header — superclass + interface list + generic signature. Caught here so a consumer
+ * that {@code extends} or {@code implements} an internal Kafka type is flagged even if its
+ * body never names the type. Generics ({@code class C extends Foo}) hide
+ * inside the signature string and are pulled out via {@link SignatureReader}.
+ */
+ @Override
+ public void visit(int version, int access, String name, String signature,
+ String superName, String[] interfaces) {
+ this.currentClass = name == null ? "" : name.replace('/', '.');
+ if (superName != null) {
+ headerRefs.add(new PendingReference(superName, null, -1));
+ }
+ if (interfaces != null) {
+ for (String iface : interfaces) {
+ headerRefs.add(new PendingReference(iface, null, -1));
+ }
+ }
+ collectSignatureRefs(signature, null, -1, headerRefs);
+ }
+
+ /**
+ * Class-level annotations. Two jobs: (a) capture the reason on
+ * {@code @SuppressKafkaInternalApiUsage} so header refs can be silenced, and (b) treat the
+ * annotation's own type as a reference — an {@code @InternalAnnotation} on a consumer class
+ * is still a reference into Kafka internals.
+ */
+ @Override
+ public AnnotationVisitor visitAnnotation(String descriptor, boolean visible) {
+ if (SUPPRESS_DESCRIPTOR.equals(descriptor)) {
+ return new ReasonCaptureVisitor(r -> classSuppression = r);
+ }
+ headerRefs.add(new PendingReference(stripDescriptor(descriptor), null, -1));
+ return null;
+ }
+
+ /**
+ * Field declarations. The field's declared type (descriptor) and its full generic form
+ * (signature) can both name internal types — e.g. {@code private InternalCache cache} or
+ * {@code Map data}. Refs are buffered so a field-level
+ * {@code @SuppressKafkaInternalApiUsage} (visited after the declaration) can suppress them.
+ */
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor,
+ String signature, Object value) {
+ return new FieldVisitor(ASM_API) {
+ private String fieldSuppression;
+ private final List fieldRefs = new ArrayList<>();
+ {
+ fieldRefs.add(new PendingReference(stripDescriptor(descriptor), name, -1));
+ collectSignatureRefs(signature, name, -1, fieldRefs);
+ }
+ @Override
+ public AnnotationVisitor visitAnnotation(String d, boolean v) {
+ if (SUPPRESS_DESCRIPTOR.equals(d)) {
+ return new ReasonCaptureVisitor(r -> fieldSuppression = r);
+ }
+ fieldRefs.add(new PendingReference(stripDescriptor(d), name, -1));
+ return null;
+ }
+ @Override
+ public void visitEnd() {
+ String reason = effective(fieldSuppression);
+ flush(fieldRefs, reason);
+ }
+ };
+ }
+
+ /**
+ * Per-method delegate. Each method's body is walked by a
+ * {@link ReferenceCollectingMethodVisitor} that records every kind of bytecode-level
+ * reference (see its own javadoc).
+ */
+ @Override
+ public MethodVisitor visitMethod(int access, String name, String descriptor,
+ String signature, String[] exceptions) {
+ return new ReferenceCollectingMethodVisitor(name, descriptor, signature, exceptions);
+ }
+
+ /**
+ * End of class. Flushes the buffered header refs (superclass / interfaces / generic
+ * signature / non-suppress annotations) now that the class-level
+ * {@code @SuppressKafkaInternalApiUsage} reason — which is visited after the
+ * header in ASM's callback order — is finally known.
+ */
+ @Override
+ public void visitEnd() {
+ flush(headerRefs, classSuppression);
+ }
+
+ /** {@code memberReason} takes precedence; falls back to class-level. */
+ String effective(String memberReason) {
+ return memberReason != null ? memberReason : classSuppression;
+ }
+
+ void flush(List refs, String reason) {
+ for (PendingReference r : refs) {
+ recordIfInternal(r.internalName, currentClass, r.memberName, r.line, reason, violations, suppressions);
+ }
+ }
+
+ private void collectSignatureRefs(String signature, String member, int line, List out) {
+ if (signature == null) {
+ return;
+ }
+ new SignatureReader(signature).accept(new SignatureVisitor(ASM_API) {
+ @Override
+ public void visitClassType(String name) {
+ out.add(new PendingReference(name, member, line));
+ }
+ });
+ }
+
+ /** Records type references encountered in method bodies; honours method-level + class-level suppression. */
+ private final class ReferenceCollectingMethodVisitor extends MethodVisitor {
+ private final String methodName;
+ private final List headerBuffer = new ArrayList<>();
+ private int currentLine = -1;
+ private String methodSuppression;
+ private boolean codeStarted;
+
+ ReferenceCollectingMethodVisitor(String name, String descriptor, String signature, String[] exceptions) {
+ super(ASM_API);
+ this.methodName = name;
+ Type methodType = Type.getMethodType(descriptor);
+ headerBuffer.add(new PendingReference(referenceInternalName(methodType.getReturnType()), name, -1));
+ for (Type arg : methodType.getArgumentTypes()) {
+ headerBuffer.add(new PendingReference(referenceInternalName(arg), name, -1));
+ }
+ if (exceptions != null) {
+ for (String ex : exceptions) {
+ headerBuffer.add(new PendingReference(ex, name, -1));
+ }
+ }
+ collectSignatureRefs(signature, name, -1, headerBuffer);
+ }
+
+ /**
+ * Method-level annotations. {@code @SuppressKafkaInternalApiUsage} captures the reason
+ * to silence both header refs (return/param/exception types) and body refs in this
+ * method. All other annotation types are themselves recorded as references — an
+ * annotation IS a class reference, even if it's never used elsewhere.
+ */
+ @Override
+ public AnnotationVisitor visitAnnotation(String d, boolean v) {
+ if (SUPPRESS_DESCRIPTOR.equals(d)) {
+ return new ReasonCaptureVisitor(r -> methodSuppression = r);
+ }
+ headerBuffer.add(new PendingReference(stripDescriptor(d), methodName, -1));
+ return null;
+ }
+
+ /**
+ * Marker fired when ASM transitions from method header to method body. All method-level
+ * annotations have already been visited by this point, so {@link #methodSuppression} is
+ * stable — header refs (return type / params / declared exceptions / generic signature
+ * / non-suppress annotations) are flushed now with the correct effective reason.
+ */
+ @Override
+ public void visitCode() {
+ if (!codeStarted) {
+ flush(headerBuffer, effective(methodSuppression));
+ headerBuffer.clear();
+ codeStarted = true;
+ }
+ }
+
+ /**
+ * Source line marker from the {@code LineNumberTable} debug attribute. Only fires for
+ * classes compiled with {@code javac -g} (the default). Carries no class reference of
+ * its own — we just track the current line so violations can be reported as
+ * {@code ConsumerClass#method (line N)}. Stays at -1 when debug info is stripped.
+ */
+ @Override
+ public void visitLineNumber(int line, org.objectweb.asm.Label start) {
+ this.currentLine = line;
+ }
+
+ /**
+ * Type-as-operand instructions: {@code NEW}, {@code ANEWARRAY}, {@code CHECKCAST},
+ * {@code INSTANCEOF}. The operand is the class being instantiated, cast to, or tested —
+ * exactly the cases where a consumer reaches an internal type without naming it via a
+ * method call or field access.
+ */
+ @Override
+ public void visitTypeInsn(int opcode, String type) {
+ recordBody(type);
+ }
+
+ /**
+ * Field-access instructions: {@code GETFIELD}, {@code PUTFIELD}, {@code GETSTATIC},
+ * {@code PUTSTATIC}. Both the field's owner (the declaring class) and its
+ * type (descriptor) can name internal Kafka classes — e.g. reading
+ * {@code InternalClass.CONSTANT} (owner is internal) or writing to a field whose type
+ * is internal.
+ */
+ @Override
+ public void visitFieldInsn(int opcode, String owner, String name, String descriptor) {
+ recordBody(owner);
+ recordBody(stripDescriptor(descriptor));
+ }
+
+ /**
+ * Method-invocation instructions: {@code INVOKEVIRTUAL}, {@code INVOKESPECIAL},
+ * {@code INVOKESTATIC}, {@code INVOKEINTERFACE}. Three reference slots per call site:
+ * the owner (declaring class), the return type, and each argument type. Catches both
+ * "calls into an internal class" and "passes an internal type as an argument or
+ * receives one as a return".
+ */
+ @Override
+ public void visitMethodInsn(int opcode, String owner, String name,
+ String descriptor, boolean isInterface) {
+ recordBody(owner);
+ Type methodType = Type.getMethodType(descriptor);
+ recordBody(referenceInternalName(methodType.getReturnType()));
+ for (Type arg : methodType.getArgumentTypes()) {
+ recordBody(referenceInternalName(arg));
+ }
+ }
+
+ /**
+ * {@code INVOKEDYNAMIC}. Emitted by {@code javac} for lambdas, method references, and
+ * {@code String} concatenation since Java 9. Only the call-site descriptor — return
+ * type and argument types — is walked. The bootstrap method handle itself is not
+ * followed, because the LambdaMetafactory machinery is JDK-owned and the user-visible
+ * references show up via the descriptor anyway.
+ */
+ @Override
+ public void visitInvokeDynamicInsn(String name, String descriptor,
+ Handle bootstrapMethodHandle, Object... bootstrapMethodArguments) {
+ Type methodType = Type.getMethodType(descriptor);
+ recordBody(referenceInternalName(methodType.getReturnType()));
+ for (Type arg : methodType.getArgumentTypes()) {
+ recordBody(referenceInternalName(arg));
+ }
+ }
+
+ /**
+ * {@code LDC} loads a constant onto the operand stack — int / long / float / double /
+ * String / and, relevantly here, {@code Class} literals such as
+ * {@code InternalClass.class}. Only the {@link Type} case is interesting; primitive
+ * and string constants carry no class reference.
+ */
+ @Override
+ public void visitLdcInsn(Object value) {
+ if (value instanceof Type) {
+ Type t = (Type) value;
+ if (t.getSort() == Type.OBJECT || t.getSort() == Type.ARRAY) {
+ recordBody(t.getInternalName());
+ }
+ }
+ }
+
+ /**
+ * {@code MULTIANEWARRAY} for multi-dimensional arrays
+ * ({@code new InternalClass[3][3]}). Single-dimensional array allocation goes through
+ * {@code ANEWARRAY} which is handled by {@link #visitTypeInsn}.
+ */
+ @Override
+ public void visitMultiANewArrayInsn(String descriptor, int numDimensions) {
+ recordBody(stripDescriptor(descriptor));
+ }
+
+ /**
+ * Exception-handler table entries: {@code catch (InternalKafkaException e)}. The
+ * {@code type} is the internal name of the caught exception (or {@code null} for a
+ * {@code finally} block, which has no exception type to record).
+ */
+ @Override
+ public void visitTryCatchBlock(org.objectweb.asm.Label start, org.objectweb.asm.Label end,
+ org.objectweb.asm.Label handler, String type) {
+ if (type != null) {
+ recordBody(type);
+ }
+ }
+
+ /**
+ * End of method. {@link #visitCode} is never called for abstract / native methods —
+ * they have no body — so their header refs would otherwise leak unflushed. This safety
+ * net guarantees every method's return/param/exception types are still audited.
+ */
+ @Override
+ public void visitEnd() {
+ if (!codeStarted) {
+ flush(headerBuffer, effective(methodSuppression));
+ headerBuffer.clear();
+ }
+ }
+
+ private void recordBody(String internalName) {
+ recordIfInternal(internalName, currentClass, methodName, currentLine,
+ effective(methodSuppression), violations, suppressions);
+ }
+ }
+ }
+}
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiChecker.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiChecker.java
new file mode 100644
index 0000000000000..eea888f4b55a9
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiChecker.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Facade for the KIP-1265 public-API checker. Pre-scans the supplied Kafka jars into an
+ * {@link ApiSurface} once and delegates each check to a focused validator:
+ *
+ * - {@link JavadocConsistencyValidator} for {@code MISSING_JAVADOC} / {@code MISSING_PUBLICAPI_ANNOTATION}.
+ * - {@link CascadeValidator} for {@code INVALID_RETURN_TYPE} / {@code INVALID_PARAMETER_TYPE} /
+ * {@code INVALID_EXCEPTION_TYPE} with {@code @SuppressKafkaInternalApiUsage} suppression.
+ * - {@link PluginDeveloperApiUsageScanner} for the consumer-side check (via {@link #checkBytecode}).
+ *
+ *
+ * All bytecode reading is done via ASM; no classloading, no {@code LinkageError} risk from
+ * broken transitive deps.
+ */
+public class PublicApiChecker {
+
+ private final ApiSurface surface;
+
+ /**
+ * @param projectJars jars produced by the project being checked. Their classes drive
+ * the MISSING_JAVADOC iteration and are cascade-checked for method
+ * signature leaks.
+ * @param referenceJars jars from sibling Kafka modules this project depends on. Their
+ * classes contribute to the {@code @InterfaceAudience.Public}
+ * membership set so cross-module references resolve, but they don't
+ * take part in this project's own javadoc consistency or cascade
+ * iteration (each module checks its own surface).
+ */
+ public PublicApiChecker(List projectJars, List referenceJars) throws IOException {
+ this.surface = ApiSurfaceScanner.scan(projectJars, referenceJars);
+ }
+
+ /** Convenience for the consumer-side scanner: no separate reference jars needed. */
+ public PublicApiChecker(List kafkaJars) throws IOException {
+ this(kafkaJars, java.util.Collections.emptyList());
+ }
+
+ /**
+ * Cross-validate the javadoc HTML against the project's bytecode audience annotations and
+ * cascade-check public method signatures for internal-type leaks.
+ */
+ public CheckResult checkPublicApiConsistency(File javadocJar) throws IOException {
+ CheckResult javadoc = JavadocConsistencyValidator.validate(javadocJar, surface);
+ CheckResult cascade = CascadeValidator.validate(surface);
+
+ List violations = new ArrayList<>(javadoc.violations());
+ violations.addAll(cascade.violations());
+ List suppressions = new ArrayList<>(javadoc.suppressions());
+ suppressions.addAll(cascade.suppressions());
+ return new CheckResult(violations, suppressions);
+ }
+
+ /**
+ * Consumer-side check: walk compiled bytecode at the given roots and flag references to any
+ * {@code org.apache.kafka.*} class that isn't effectively {@code @InterfaceAudience.Public}.
+ * Roots may be class directories or jar archives.
+ */
+ public CheckResult checkBytecode(List classFileRoots) throws IOException {
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(this::isPublicApi);
+ return scanner.scan(classFileRoots);
+ }
+
+ /**
+ * Filter the supplied class-file roots to those that exist on disk. Both adapters
+ * (Gradle task, Maven mojo) seed the input from build configuration that may include
+ * directories the build hasn't produced yet (e.g. test classes when there are no tests).
+ */
+ public static List collectExistingRoots(Iterable roots) {
+ List existing = new ArrayList<>();
+ for (File root : roots) {
+ if (root.exists()) {
+ existing.add(root);
+ }
+ }
+ return existing;
+ }
+
+ /**
+ * Consumer-side predicate: true if the binary class name (e.g.
+ * {@code org.apache.kafka.clients.producer.KafkaProducer} or
+ * {@code org.apache.kafka.clients.admin.OffsetSpec$LatestSpec}) is effectively
+ * {@code @InterfaceAudience.Public} — either via a direct annotation or by inheritance
+ * from an enclosing class. A direct {@code @InterfaceAudience.Private} overrides an
+ * inherited Public. Non-{@code org.apache.kafka.*} types are treated as out of scope.
+ *
+ * Deprecation is intentionally not a bypass on the consumer side:
+ * {@code @Deprecated} internal types are exactly the ones most likely to be removed in
+ * the next release, so consumer references to them deserve a violation. The cascade
+ * validator has its own deprecation bypass for the producer-side leak check, which is
+ * fine — that side is just asking "did we expose this in the API surface?", whereas the
+ * consumer side is asking "am I going to break when Kafka removes this?".
+ */
+ public boolean isPublicApi(String binaryClassName) {
+ if (!binaryClassName.startsWith("org.apache.kafka.")) {
+ return true; // not in scope
+ }
+ return surface.isEffectivelyPublic(binaryClassName);
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java
new file mode 100644
index 0000000000000..acfb5a61397ba
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+/**
+ * Represents a violation of the public API rules.
+ */
+public class PublicApiViolation {
+ /**
+ * Rendered into a suppression's description when the {@code @SuppressKafkaInternalApiUsage}
+ * annotation carried no {@code value()}. Stable marker so the reporter and the build tasks
+ * can surface unjustified suppressions consistently.
+ */
+ public static final String NO_REASON_MARKER = "(no reason given)";
+
+ private final String className;
+ private final String violationType;
+ private final String description;
+ private final String memberName;
+
+ public PublicApiViolation(String className, String violationType, String description, String memberName) {
+ this.className = className;
+ this.violationType = violationType;
+ this.description = description;
+ this.memberName = memberName;
+ }
+
+ public String getClassName() {
+ return className;
+ }
+
+ public String getViolationType() {
+ return violationType;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public String getMemberName() {
+ return memberName;
+ }
+
+ @Override
+ public String toString() {
+ if (memberName != null && !memberName.isEmpty()) {
+ return String.format("[%s] %s.%s: %s", violationType, className, memberName, description);
+ } else {
+ return String.format("[%s] %s: %s", violationType, className, description);
+ }
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ PublicApiViolation that = (PublicApiViolation) o;
+
+ if (!className.equals(that.className)) return false;
+ if (!violationType.equals(that.violationType)) return false;
+ if (!description.equals(that.description)) return false;
+ return memberName != null ? memberName.equals(that.memberName) : that.memberName == null;
+ }
+
+ /**
+ * @return true iff this suppression's description ends with {@link #NO_REASON_MARKER} —
+ * i.e. the {@code @SuppressKafkaInternalApiUsage} annotation carried no
+ * {@code value()}. KIP-1265 describes the reason as required, so the checker warns
+ * on unjustified suppressions.
+ */
+ public boolean lacksReason() {
+ return description != null && description.endsWith("reason: " + NO_REASON_MARKER);
+ }
+
+ @Override
+ public int hashCode() {
+ int result = className.hashCode();
+ result = 31 * result + violationType.hashCode();
+ result = 31 * result + description.hashCode();
+ result = 31 * result + (memberName != null ? memberName.hashCode() : 0);
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ReasonCaptureVisitor.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ReasonCaptureVisitor.java
new file mode 100644
index 0000000000000..6d30aa2272746
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ReasonCaptureVisitor.java
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.util.function.Consumer;
+
+/**
+ * Captures the {@code value()} string of a {@code @SuppressKafkaInternalApiUsage} annotation.
+ * If the annotation is present but its {@code value()} was omitted, {@link #visitEnd()} routes
+ * an empty string to the setter — the convention both checkers use to mean "suppressed without
+ * a reason."
+ */
+final class ReasonCaptureVisitor extends AnnotationVisitor {
+
+ private final Consumer setter;
+ private boolean assigned;
+
+ ReasonCaptureVisitor(Consumer setter) {
+ super(Opcodes.ASM9);
+ this.setter = setter;
+ }
+
+ @Override
+ public void visit(String name, Object value) {
+ if ("value".equals(name) && value instanceof String) {
+ setter.accept((String) value);
+ assigned = true;
+ }
+ }
+
+ @Override
+ public void visitEnd() {
+ if (!assigned) {
+ setter.accept("");
+ }
+ }
+}
\ No newline at end of file
diff --git a/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java
new file mode 100644
index 0000000000000..ada8c0eb105eb
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.stream.Collectors;
+
+/**
+ * Generates reports for public API violations.
+ */
+public class ViolationReporter {
+
+ /**
+ * Write violations + suppressions to a text report file. Suppressions are rendered in a
+ * dedicated section so reviewers can audit every place {@code @SuppressKafkaInternalApiUsage}
+ * has been applied — together with the reason supplied to the annotation.
+ */
+ public void writeTextReport(List violations,
+ List suppressions,
+ File reportFile) throws IOException {
+ reportFile.getParentFile().mkdirs();
+ List safeSuppressions =
+ suppressions == null ? Collections.emptyList() : suppressions;
+
+ // Report contents must be reproducible: any two runs over the same inputs should produce
+ // byte-identical reports so CI diff tooling and reviewers can compare cleanly. That rules
+ // out a wall-clock timestamp (omitted) and HashMap-order grouping (replaced with TreeMap
+ // + a stable per-list sort by class then member).
+ try (PrintWriter writer = new PrintWriter(Files.newBufferedWriter(reportFile.toPath(), StandardCharsets.UTF_8))) {
+ writer.println("Apache Kafka Public API Violation Report");
+ writer.println("========================================");
+ writer.println("Total violations: " + violations.size());
+ writer.println("Total suppressions: " + safeSuppressions.size());
+ writer.println();
+
+ if (violations.isEmpty()) {
+ writer.println("No violations found.");
+ } else {
+ Map> violationsByType = groupSorted(
+ violations, PublicApiViolation::getViolationType);
+ for (Map.Entry> entry : violationsByType.entrySet()) {
+ writer.println("## " + entry.getKey() + " (" + entry.getValue().size() + " violations)");
+ writer.println();
+ for (PublicApiViolation violation : entry.getValue()) {
+ writer.println("- " + violation.toString());
+ }
+ writer.println();
+ }
+
+ writer.println("## Summary by Class");
+ writer.println();
+ Map> violationsByClass = groupSorted(
+ violations, PublicApiViolation::getClassName);
+ for (Map.Entry> entry : violationsByClass.entrySet()) {
+ writer.println("### " + entry.getKey() + " (" + entry.getValue().size() + " violations)");
+ for (PublicApiViolation violation : entry.getValue()) {
+ writer.println(" - " + violation.getViolationType() + ": " + violation.getDescription());
+ }
+ writer.println();
+ }
+ }
+
+ if (!safeSuppressions.isEmpty()) {
+ writer.println("## Suppressions (" + safeSuppressions.size() + " entries)");
+ writer.println("Checks skipped due to @SuppressKafkaInternalApiUsage.");
+ writer.println("Each line shows the reason supplied to the annotation; review periodically.");
+ writer.println();
+ List sortedSuppressions = new ArrayList<>(safeSuppressions);
+ sortedSuppressions.sort(VIOLATION_ORDER);
+ for (PublicApiViolation suppression : sortedSuppressions) {
+ writer.println("- " + suppression.getDescription());
+ }
+ writer.println();
+ }
+ }
+ }
+
+ /** Stable sort key for any list of violations: class, then member, then description. */
+ private static final Comparator VIOLATION_ORDER =
+ Comparator.comparing(PublicApiViolation::getClassName, Comparator.nullsFirst(String::compareTo))
+ .thenComparing(PublicApiViolation::getMemberName, Comparator.nullsFirst(String::compareTo))
+ .thenComparing(PublicApiViolation::getDescription, Comparator.nullsFirst(String::compareTo));
+
+ private static > Map> groupSorted(
+ List violations,
+ java.util.function.Function keyFn) {
+ Map> grouped = violations.stream()
+ .collect(Collectors.groupingBy(keyFn, TreeMap::new, Collectors.toList()));
+ grouped.values().forEach(list -> list.sort(VIOLATION_ORDER));
+ return grouped;
+ }
+
+ /** Back-compat overload — call sites that don't yet pass suppressions. */
+ public void writeTextReport(List violations, File reportFile) throws IOException {
+ writeTextReport(violations, Collections.emptyList(), reportFile);
+ }
+
+ /**
+ * Print violations to console with color coding (if supported). Suppressions are listed at the
+ * end so reviewers see what was waived (each with reason).
+ */
+ public void printToConsole(List violations,
+ List suppressions,
+ boolean useColors) {
+ String redColor = useColors ? "\u001B[31m" : "";
+ String greenColor = useColors ? "\u001B[32m" : "";
+ String yellowColor = useColors ? "\u001B[33m" : "";
+ String cyanColor = useColors ? "\u001B[36m" : "";
+ String resetColor = useColors ? "\u001B[0m" : "";
+
+ if (violations.isEmpty()) {
+ System.out.println(greenColor + "No public API violations found." + resetColor);
+ } else {
+ System.out.println(redColor + "Found " + violations.size() + " public API violation(s):" + resetColor);
+ System.out.println();
+ for (PublicApiViolation violation : violations) {
+ System.out.println(yellowColor + violation.toString() + resetColor);
+ }
+ System.out.println();
+ System.out.println("Please fix these violations to ensure API compatibility.");
+ }
+
+ if (suppressions != null && !suppressions.isEmpty()) {
+ System.out.println();
+ System.out.println(cyanColor + suppressions.size()
+ + " check(s) suppressed via @SuppressKafkaInternalApiUsage:" + resetColor);
+ for (PublicApiViolation suppression : suppressions) {
+ System.out.println(cyanColor + " " + suppression.getDescription() + resetColor);
+ }
+ }
+ }
+
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceScannerTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceScannerTest.java
new file mode 100644
index 0000000000000..3e2c8b42f7843
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceScannerTest.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ApiSurfaceScannerTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void publicTopLevelClass_isInDirectAndEffectiveSets() throws IOException {
+ File jar = projectJar("proj.jar",
+ AsmClassFactory.klass("org.apache.kafka.foo.Bar").access(Opcodes.ACC_PUBLIC).publicApi());
+ ApiSurface s = scan(jar);
+
+ assertTrue(containsDotted(s.directPublic(), "org.apache.kafka.foo.Bar"));
+ assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Bar"));
+ assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Bar"));
+ assertEquals(jar, s.jarOf("org.apache.kafka.foo.Bar"));
+ }
+
+ @Test
+ void privateNested_overridesInheritedPublic() throws IOException {
+ File jar = projectJar("proj.jar",
+ AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+ AsmClassFactory.klass("org.apache.kafka.foo.Outer$Inner").access(Opcodes.ACC_PUBLIC).privateApi());
+ ApiSurface s = scan(jar);
+
+ assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Outer"));
+ assertFalse(s.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"),
+ "@Private must override inherited @Public");
+ assertFalse(containsDotted(s.directPublic(), "org.apache.kafka.foo.Outer.Inner"));
+ assertFalse(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Outer.Inner"));
+ }
+
+ @Test
+ void unannotatedNested_inheritsPublicFromOuter() throws IOException {
+ File jar = projectJar("proj.jar",
+ AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+ AsmClassFactory.klass("org.apache.kafka.foo.Outer$Inner").access(Opcodes.ACC_PUBLIC));
+ ApiSurface s = scan(jar);
+
+ assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"));
+ // Inner has no direct @Public, so it's not in the MISSING_JAVADOC iteration set.
+ assertFalse(containsDotted(s.directPublic(), "org.apache.kafka.foo.Outer.Inner"));
+ // Externally visible + effective @Public + owned → in the cascade iteration set.
+ assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Outer.Inner"));
+ }
+
+ @Test
+ void packagePrivateNested_inheritsPublicButNotInCascade() throws IOException {
+ // Nested-class header is ACC_PUBLIC (compiler convention) but the InnerClasses entry says
+ // package-private. The scanner must trust the InnerClasses entry over the header.
+ File jar = projectJar("proj.jar",
+ AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+ AsmClassFactory.klass("org.apache.kafka.foo.Outer$Inner")
+ .access(Opcodes.ACC_PUBLIC)
+ .nestedAccess(0));
+ ApiSurface s = scan(jar);
+
+ assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"),
+ "chain walk sees inherited @Public regardless of visibility");
+ // The effectivePublic set contains all owned + effective-Public classes; CascadeValidator
+ // filters on isExternallyVisible() at the iteration site rather than at the scanner.
+ assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.foo.Outer.Inner"));
+ ClassFacts innerFacts = s.factsOf("org.apache.kafka.foo.Outer$Inner");
+ assertFalse(innerFacts.isExternallyVisible(),
+ "package-private nested classes are filtered out by CascadeValidator, not the scanner");
+ }
+
+ @Test
+ void deprecatedClass_isExcludedFromIterationSets() throws IOException {
+ File jar = projectJar("proj.jar",
+ AsmClassFactory.klass("org.apache.kafka.foo.OldBar")
+ .access(Opcodes.ACC_PUBLIC)
+ .publicApi()
+ .deprecated());
+ ApiSurface s = scan(jar);
+
+ assertTrue(s.isDeprecated("org.apache.kafka.foo.OldBar"));
+ assertTrue(s.directPublic().isEmpty(), "deprecated classes are out of scope on both validation sides");
+ assertTrue(s.effectivePublic().isEmpty());
+ // isEffectivelyPublic answers the audience question only — deprecation is handled
+ // separately by callers (CascadeValidator skips deprecated refs before this check).
+ assertTrue(s.isEffectivelyPublic("org.apache.kafka.foo.OldBar"));
+ }
+
+ @Test
+ void anonymousAndLambdaClasses_areSkippedEntirely() throws IOException {
+ File jar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Outer").access(Opcodes.ACC_PUBLIC).publicApi())
+ .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Outer$1").access(Opcodes.ACC_PUBLIC))
+ .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Outer$$Lambda$0").access(Opcodes.ACC_PUBLIC))
+ .writeTo(tempDir, "proj.jar");
+ ApiSurface s = scan(jar);
+
+ assertNotNull(s.factsOf("org.apache.kafka.foo.Outer"));
+ assertNull(s.factsOf("org.apache.kafka.foo.Outer$1"),
+ "anonymous classes (digit after $) are not part of the API surface");
+ assertNull(s.factsOf("org.apache.kafka.foo.Outer$$Lambda$0"),
+ "lambda / synthetic-accessor classes ($$) are skipped");
+ }
+
+ @Test
+ void nonKafkaClasses_areSkipped() throws IOException {
+ File jar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("com.example.External").access(Opcodes.ACC_PUBLIC).publicApi())
+ .writeTo(tempDir, "proj.jar");
+ ApiSurface s = scan(jar);
+
+ assertNull(s.factsOf("com.example.External"));
+ assertTrue(s.directPublic().isEmpty());
+ }
+
+ @Test
+ void packageInfoAndModuleInfo_areSkipped() throws IOException {
+ // The scanner short-circuits on the binary name before reading any bytes, so the content
+ // of these entries is irrelevant — pass garbage to prove they aren't parsed.
+ File jar = TempJarBuilder.jar()
+ .addEntry("org/apache/kafka/foo/package-info.class", new byte[]{0})
+ .addEntry("module-info.class", new byte[]{0})
+ .addClass(AsmClassFactory.klass("org.apache.kafka.foo.Bar").access(Opcodes.ACC_PUBLIC).publicApi())
+ .writeTo(tempDir, "proj.jar");
+ ApiSurface s = scan(jar);
+
+ assertNotNull(s.factsOf("org.apache.kafka.foo.Bar"));
+ assertNull(s.factsOf("org.apache.kafka.foo.package-info"));
+ assertNull(s.factsOf("module-info"));
+ }
+
+ @Test
+ void referenceJar_contributesMembershipButNotIteration() throws IOException {
+ File proj = projectJar("proj.jar",
+ AsmClassFactory.klass("org.apache.kafka.proj.OwnedClass").access(Opcodes.ACC_PUBLIC).publicApi());
+ File ref = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.ref.RefClass").access(Opcodes.ACC_PUBLIC).publicApi())
+ .writeTo(tempDir, "ref.jar");
+ ApiSurface s = ApiSurfaceScanner.scan(List.of(proj), List.of(ref));
+
+ // Reference class is recorded and contributes to the membership set so cross-module
+ // @Public references resolve in cascade checks…
+ assertNotNull(s.factsOf("org.apache.kafka.ref.RefClass"));
+ assertTrue(s.isEffectivelyPublic("org.apache.kafka.ref.RefClass"));
+ // …but doesn't take part in this project's MISSING_JAVADOC / cascade iteration.
+ assertFalse(containsDotted(s.directPublic(), "org.apache.kafka.ref.RefClass"));
+ assertFalse(containsDotted(s.effectivePublic(), "org.apache.kafka.ref.RefClass"));
+ // Project-owned class participates in both iteration sets.
+ assertTrue(containsDotted(s.directPublic(), "org.apache.kafka.proj.OwnedClass"));
+ assertTrue(containsDotted(s.effectivePublic(), "org.apache.kafka.proj.OwnedClass"));
+ }
+
+ @Test
+ void crossJarDuplicate_projectJarWins() throws IOException {
+ // Same binary name in both jars — the project jar is scanned first and keeps ownership.
+ AsmClassFactory.ClassBuilder dup =
+ AsmClassFactory.klass("org.apache.kafka.dup.Dup").access(Opcodes.ACC_PUBLIC).publicApi();
+ File proj = TempJarBuilder.jar().addClass(dup).writeTo(tempDir, "proj.jar");
+ // Re-build a new ClassBuilder for the ref jar to avoid sharing state with the above call.
+ File ref = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.dup.Dup").access(Opcodes.ACC_PUBLIC).publicApi())
+ .writeTo(tempDir, "ref.jar");
+
+ ApiSurface s = ApiSurfaceScanner.scan(List.of(proj), List.of(ref));
+
+ assertEquals(proj, s.jarOf("org.apache.kafka.dup.Dup"));
+ assertTrue(containsDotted(s.directPublic(), "org.apache.kafka.dup.Dup"),
+ "project-jar entry establishes ownership so the class participates in iteration");
+ }
+
+ // Helpers
+
+ private File projectJar(String fileName, AsmClassFactory.ClassBuilder... builders) throws IOException {
+ TempJarBuilder jar = TempJarBuilder.jar();
+ for (AsmClassFactory.ClassBuilder b : builders) jar.addClass(b);
+ return jar.writeTo(tempDir, fileName);
+ }
+
+ private static ApiSurface scan(File... projectJars) throws IOException {
+ return ApiSurfaceScanner.scan(List.of(projectJars), Collections.emptyList());
+ }
+
+ private static boolean containsDotted(java.util.Collection set, String dottedName) {
+ return set.stream().anyMatch(f -> f.dottedName().equals(dottedName));
+ }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceTest.java
new file mode 100644
index 0000000000000..42155a77c2ee3
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/ApiSurfaceTest.java
@@ -0,0 +1,200 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class ApiSurfaceTest {
+
+ private static final File JAR_A = new File("/tmp/a.jar");
+ private static final File JAR_B = new File("/tmp/b.jar");
+
+ @Test
+ void factsOf_acceptsBothBinaryAndDottedNames() {
+ ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+ ApiSurface surface = ApiSurface.builder().recordClass(inner, JAR_A).build();
+
+ assertSame(inner, surface.factsOf("org.apache.kafka.foo.Outer$Inner"));
+ assertSame(inner, surface.factsOf("org.apache.kafka.foo.Outer.Inner"));
+ }
+
+ @Test
+ void factsOf_returnsNullForUnknownClass() {
+ ApiSurface surface = ApiSurface.builder().build();
+ assertNull(surface.factsOf("org.apache.kafka.NotThere"));
+ }
+
+ @Test
+ void jarOf_returnsRecordedJar() {
+ ClassFacts f = facts("org.apache.kafka.foo.Bar");
+ ApiSurface surface = ApiSurface.builder().recordClass(f, JAR_A).build();
+ assertEquals(JAR_A, surface.jarOf("org.apache.kafka.foo.Bar"));
+ assertNull(surface.jarOf("org.apache.kafka.NotThere"));
+ }
+
+ @Test
+ void recordClass_jarRegistrationIsFirstWins() {
+ // The scanner already de-dupes by binary name before calling recordClass, but the builder
+ // defends with putIfAbsent on the jar map so jarOf stays stable if a duplicate slips through.
+ ClassFacts f = facts("org.apache.kafka.foo.Bar");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(f, JAR_A)
+ .recordClass(f, JAR_B)
+ .build();
+ assertEquals(JAR_A, surface.jarOf("org.apache.kafka.foo.Bar"));
+ }
+
+ @Test
+ void isEffectivelyPublic_acceptsBothNameForms() {
+ // isEffectivelyPublic walks the enclosing chain on facts. Inner inherits via Outer's @Public.
+ ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.PUBLIC_API);
+ ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(outer, JAR_A)
+ .recordClass(inner, JAR_A)
+ .build();
+
+ assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"));
+ assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer.Inner"));
+ }
+
+ @Test
+ void isEffectivelyPublic_skipsMissingIntermediates() {
+ // Bug #5 regression: an anonymous/synthetic intermediate like Outer$1 is filtered out
+ // of the surface by ApiSurfaceScanner#isSyntheticOrAnonymous, but a named nested class
+ // *inside* the synthetic (Outer$1$Inner) should still inherit Outer's @Public. The walk
+ // has to step past the missing intermediate lexically rather than stopping at the gap.
+ ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.PUBLIC_API);
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(outer, JAR_A)
+ .build();
+
+ assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer$1$Inner"),
+ "anonymous-enclosed nested class must still inherit Outer's @Public");
+ }
+
+ @Test
+ void isEffectivelyPublic_falseWhenNotAnnotated() {
+ ClassFacts f = facts("org.apache.kafka.foo.Bar");
+ ApiSurface surface = ApiSurface.builder().recordClass(f, JAR_A).build();
+ assertFalse(surface.isEffectivelyPublic("org.apache.kafka.foo.Bar"));
+ assertFalse(surface.isEffectivelyPublic("org.apache.kafka.UnknownClass"));
+ }
+
+ @Test
+ void isEffectivelyPublic_privateNestedOverridesInheritedPublic() {
+ ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.PUBLIC_API);
+ ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner", ClassFacts.Flag.PRIVATE_API);
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(outer, JAR_A).recordClass(inner, JAR_A)
+ .build();
+
+ assertTrue(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer"));
+ assertFalse(surface.isEffectivelyPublic("org.apache.kafka.foo.Outer$Inner"));
+ }
+
+ @Test
+ void isDeprecated_trueWhenClassItselfIsDeprecated() {
+ ClassFacts f = facts("org.apache.kafka.foo.Bar", ClassFacts.Flag.DEPRECATED);
+ ApiSurface surface = ApiSurface.builder().recordClass(f, JAR_A).build();
+ assertTrue(surface.isDeprecated("org.apache.kafka.foo.Bar"));
+ }
+
+ @Test
+ void isDeprecated_inheritsFromEnclosingClass() {
+ // Outer is @Deprecated; Inner is not directly annotated. The walk up the enclosing chain
+ // (Outer$Inner → Outer) finds the deprecation and propagates it.
+ ClassFacts outer = facts("org.apache.kafka.foo.Outer", ClassFacts.Flag.DEPRECATED);
+ ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(outer, JAR_A)
+ .recordClass(inner, JAR_A)
+ .build();
+
+ assertTrue(surface.isDeprecated("org.apache.kafka.foo.Outer$Inner"));
+ assertTrue(surface.isDeprecated("org.apache.kafka.foo.Outer.Inner"));
+ }
+
+ @Test
+ void isDeprecated_falseWhenNeitherSelfNorEnclosingIsDeprecated() {
+ ClassFacts outer = facts("org.apache.kafka.foo.Outer");
+ ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(outer, JAR_A)
+ .recordClass(inner, JAR_A)
+ .build();
+
+ assertFalse(surface.isDeprecated("org.apache.kafka.foo.Outer$Inner"));
+ }
+
+ @Test
+ void isDeprecated_falseWhenClassNotInSurface() {
+ // factsOf returns null on the very first iteration; the walk exits cleanly with false.
+ ApiSurface surface = ApiSurface.builder().build();
+ assertFalse(surface.isDeprecated("org.apache.kafka.NotThere"));
+ }
+
+ @Test
+ void isDeprecated_falseWhenEnclosingNotInSurface() {
+ // Nested class is recorded but its outer isn't. The walk finds the inner (not deprecated),
+ // then looks up the missing outer, gets null, and exits with false. No inheritance possible
+ // without facts for the enclosing class.
+ ClassFacts inner = facts("org.apache.kafka.foo.Outer$Inner");
+ ApiSurface surface = ApiSurface.builder().recordClass(inner, JAR_A).build();
+ assertFalse(surface.isDeprecated("org.apache.kafka.foo.Outer$Inner"));
+ }
+
+ @Test
+ void iterationSets_containWhatWasAdded() {
+ ClassFacts a = facts("org.apache.kafka.foo.A");
+ ClassFacts b = facts("org.apache.kafka.foo.B");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(a, JAR_A).recordClass(b, JAR_A)
+ .addEffectivePublic(a)
+ .addDirectPublic(b)
+ .build();
+
+ assertEquals(1, surface.effectivePublic().size());
+ assertTrue(surface.effectivePublic().contains(a));
+ assertEquals(1, surface.directPublic().size());
+ assertTrue(surface.directPublic().contains(b));
+ }
+
+ @Test
+ void iterationSets_areImmutable() {
+ ApiSurface surface = ApiSurface.builder().build();
+ ClassFacts f = facts("org.apache.kafka.foo.Bar");
+ assertThrows(UnsupportedOperationException.class, () -> surface.effectivePublic().add(f));
+ assertThrows(UnsupportedOperationException.class, () -> surface.directPublic().add(f));
+ }
+
+ private static ClassFacts facts(String binaryName, ClassFacts.Flag... flags) {
+ ClassFacts.Builder b = ClassFacts.builder(binaryName).sourceAccess(Opcodes.ACC_PUBLIC);
+ for (ClassFacts.Flag flag : flags) b.addFlag(flag);
+ return b.build();
+ }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java
new file mode 100644
index 0000000000000..c015e7511fb69
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java
@@ -0,0 +1,391 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class CascadeValidatorTest {
+
+ private static final String OWNER_BIN = "org.apache.kafka.api.Owner";
+ private static final String INTERNAL_BIN = "org.apache.kafka.internals.Internal";
+ private static final String INTERNAL_DESC = "Lorg/apache/kafka/internals/Internal;";
+ private static final String INTERNAL_INTERNAL_NAME = "org/apache/kafka/internals/Internal";
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void publicMethodWithInternalReturnType_emitsInvalidReturnType() throws IOException {
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("leak").returns(INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ PublicApiViolation v = r.violations().get(0);
+ assertEquals("INVALID_RETURN_TYPE", v.getViolationType());
+ assertEquals(OWNER_BIN, v.getClassName());
+ assertEquals("leak", v.getMemberName());
+ assertTrue(v.getDescription().contains(INTERNAL_BIN),
+ "description should name the leaked type: " + v.getDescription());
+ }
+
+ @Test
+ void publicMethodWithInternalParameter_emitsInvalidParameterType() throws IOException {
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("take").param(INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ assertEquals("INVALID_PARAMETER_TYPE", r.violations().get(0).getViolationType());
+ }
+
+ @Test
+ void publicMethodWithInternalException_emitsInvalidExceptionType() throws IOException {
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("boom").throwsExc(INTERNAL_INTERNAL_NAME)));
+
+ assertEquals(1, r.violations().size());
+ assertEquals("INVALID_EXCEPTION_TYPE", r.violations().get(0).getViolationType());
+ }
+
+ @Test
+ void arrayOfInternalType_recursesAndFlags() throws IOException {
+ // Array descriptors prepend "[" to the element descriptor. The validator must recurse
+ // through the array layer to reach the object element type.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("batch").returns("[" + INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ assertEquals("INVALID_RETURN_TYPE", r.violations().get(0).getViolationType());
+ }
+
+ @Test
+ void publicMethodWithInternalInGenericSignature_emitsParameterTypeViolation() throws IOException {
+ // The erased descriptor is `()Ljava/util/Map;` — no internal type. The generic signature
+ // `()Ljava/util/Map;` carries
+ // the internal type as a Map value parameter. Cascade must walk the signature.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("getStuff")
+ .returns("Ljava/util/Map;")
+ .signature("()Ljava/util/Map;")));
+
+ assertTrue(r.violations().stream().anyMatch(v -> "INVALID_PARAMETER_TYPE".equals(v.getViolationType())
+ && v.getDescription().contains(INTERNAL_BIN)),
+ "generic type argument should surface as an INVALID_PARAMETER_TYPE; got: " + r.violations());
+ }
+
+ @Test
+ void publicFieldWithInternalInGenericSignature_emitsFieldTypeViolation() throws IOException {
+ // The erased descriptor is `Ljava/util/List;`. The signature
+ // `Ljava/util/List;` exposes Internal as a
+ // List element parameter. Cascade must walk the signature.
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("things")
+ .ofType("Ljava/util/List;")
+ .signature("Ljava/util/List;")));
+
+ assertTrue(r.violations().stream().anyMatch(v -> "INVALID_FIELD_TYPE".equals(v.getViolationType())
+ && v.getDescription().contains(INTERNAL_BIN)),
+ "generic field signature should surface as an INVALID_FIELD_TYPE; got: " + r.violations());
+ }
+
+ @Test
+ void publicClassExtendingInternalType_emitsSupertypeViolation() throws IOException {
+ // A @Public class whose superclass is an internal Kafka type exposes the supertype
+ // to consumers (it's part of the type's public contract — inherited methods, casts,
+ // etc.). The cascade catches this from the class-header visit.
+ CheckResult r = run(owner().superClass("org/apache/kafka/internals/Internal"));
+
+ assertTrue(r.violations().stream().anyMatch(v -> "INVALID_SUPERTYPE".equals(v.getViolationType())
+ && v.getDescription().contains(INTERNAL_BIN)),
+ "extending an internal type must trigger an INVALID_SUPERTYPE; got: " + r.violations());
+ }
+
+ @Test
+ void publicClassImplementingInternalInterface_emitsSupertypeViolation() throws IOException {
+ CheckResult r = run(owner().interfaces("org/apache/kafka/internals/Internal"));
+
+ assertTrue(r.violations().stream().anyMatch(v -> "INVALID_SUPERTYPE".equals(v.getViolationType())
+ && v.getDescription().contains(INTERNAL_BIN)),
+ "implementing an internal interface must trigger an INVALID_SUPERTYPE; got: " + r.violations());
+ }
+
+ @Test
+ void classLevelSuppress_silencesSupertypeViolation() throws IOException {
+ // Class-level @SuppressKafkaInternalApiUsage diverts the header leak to the
+ // suppressions list, same as for method-level cascade leaks.
+ CheckResult r = run(owner()
+ .suppress("legacy-base-class")
+ .superClass("org/apache/kafka/internals/Internal"));
+
+ assertTrue(r.violations().isEmpty(), "class-level suppress should silence the header leak");
+ assertTrue(r.suppressions().stream().anyMatch(s -> s.getDescription().contains("reason: legacy-base-class")),
+ "suppression must carry the annotation reason; got: " + r.suppressions());
+ }
+
+ @Test
+ void deprecatedInternalType_isNotFlagged() throws IOException {
+ // Internal is recorded with @Deprecated → out of scope on both sides.
+ CheckResult r = runWithExtras(owner()
+ .method(AsmClassFactory.method("legacy").returns(INTERNAL_DESC)),
+ facts(INTERNAL_BIN, ClassFacts.Flag.DEPRECATED));
+
+ assertTrue(r.violations().isEmpty(),
+ "deprecated referenced type must not trigger: " + r.violations());
+ }
+
+ @Test
+ void referenceToEffectivelyPublicType_isNotFlagged() throws IOException {
+ // Internal is in the membership set → counts as part of the public API surface.
+ CheckResult r = runWithEffectivelyPublic(owner()
+ .method(AsmClassFactory.method("ok").returns(INTERNAL_DESC)),
+ INTERNAL_BIN);
+
+ assertTrue(r.violations().isEmpty());
+ }
+
+ @Test
+ void referenceToNonKafkaType_isNotFlagged() throws IOException {
+ // JDK types (java/util/Map) and third-party types are out of scope — the cascade rule
+ // only constrains references inside org.apache.kafka.*.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("safe")
+ .returns("Ljava/util/Map;")
+ .param("Lcom/example/External;")));
+
+ assertTrue(r.violations().isEmpty());
+ }
+
+ @Test
+ void privateMethod_isIgnored() throws IOException {
+ // Cascade only inspects externally-visible methods. Private leaks are tolerated
+ // because they're invisible to consumers.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("priv").access(Opcodes.ACC_PRIVATE).returns(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty());
+ }
+
+ @Test
+ void protectedMethod_emitsViolation() throws IOException {
+ // KIP-1265: protected members on an extensible @Public class are reachable to
+ // subclasses, so they count toward the public API surface and must not leak
+ // non-public types.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("prot").access(Opcodes.ACC_PROTECTED).returns(INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ assertEquals("INVALID_RETURN_TYPE", r.violations().get(0).getViolationType());
+ }
+
+ @Test
+ void publicFieldOfInternalType_emitsInvalidFieldType() throws IOException {
+ // KIP-1265: field types are part of the cascade — a public field of an internal type
+ // leaks the internal type just like a method signature does.
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("leakField").ofType(INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ PublicApiViolation v = r.violations().get(0);
+ assertEquals("INVALID_FIELD_TYPE", v.getViolationType());
+ assertEquals(OWNER_BIN, v.getClassName());
+ assertEquals("leakField", v.getMemberName());
+ assertTrue(v.getDescription().contains(INTERNAL_BIN),
+ "description should name the leaked type: " + v.getDescription());
+ }
+
+ @Test
+ void protectedFieldOfInternalType_emitsInvalidFieldType() throws IOException {
+ // protected fields on an extensible @Public class are also part of the API surface.
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("protLeak").access(Opcodes.ACC_PROTECTED).ofType(INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ assertEquals("INVALID_FIELD_TYPE", r.violations().get(0).getViolationType());
+ }
+
+ @Test
+ void privateFieldOfInternalType_isIgnored() throws IOException {
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("hidden").access(Opcodes.ACC_PRIVATE).ofType(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty());
+ }
+
+ @Test
+ void arrayFieldOfInternalType_recursesAndFlags() throws IOException {
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("buf").ofType("[" + INTERNAL_DESC)));
+
+ assertEquals(1, r.violations().size());
+ assertEquals("INVALID_FIELD_TYPE", r.violations().get(0).getViolationType());
+ }
+
+ @Test
+ void fieldLevelSuppress_movesFieldViolationToSuppressions() throws IOException {
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("leak").ofType(INTERNAL_DESC).suppress("legacy-field")));
+
+ assertTrue(r.violations().isEmpty());
+ assertEquals(1, r.suppressions().size());
+ assertTrue(r.suppressions().get(0).getDescription().contains("reason: legacy-field"));
+ }
+
+ @Test
+ void classLevelSuppress_silencesFieldLeaks() throws IOException {
+ CheckResult r = run(owner()
+ .suppress("legacy-api")
+ .field(AsmClassFactory.field("leak").ofType(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty());
+ assertEquals(1, r.suppressions().size());
+ assertTrue(r.suppressions().get(0).getDescription().contains("reason: legacy-api"));
+ }
+
+ @Test
+ void syntheticField_isIgnored() throws IOException {
+ // Compiler-generated synthetic fields (e.g. $assertionsDisabled) are not source-level API.
+ CheckResult r = run(owner()
+ .field(AsmClassFactory.field("synth")
+ .access(Opcodes.ACC_PUBLIC | Opcodes.ACC_SYNTHETIC)
+ .ofType(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty());
+ }
+
+ @Test
+ void syntheticMethod_isIgnored() throws IOException {
+ // Bridge / ACC_SYNTHETIC methods are compiler-generated, not source-level API.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("bridge").bridge().returns(INTERNAL_DESC))
+ .method(AsmClassFactory.method("synth").synthetic().returns(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty());
+ }
+
+ @Test
+ void classLevelSuppress_movesAllViolationsToSuppressions() throws IOException {
+ CheckResult r = run(owner()
+ .suppress("legacy-api")
+ .method(AsmClassFactory.method("leak").returns(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty(), "class-level suppress should silence every method");
+ assertEquals(1, r.suppressions().size());
+ PublicApiViolation s = r.suppressions().get(0);
+ assertEquals("SUPPRESSED", s.getViolationType());
+ assertTrue(s.getDescription().contains("reason: legacy-api"),
+ "suppression must carry the annotation's reason: " + s.getDescription());
+ }
+
+ @Test
+ void methodLevelSuppress_overridesClassLevelReason() throws IOException {
+ // Class-level "class-reason" applies to methods without their own annotation;
+ // a method-level annotation wins for that method.
+ CheckResult r = run(owner()
+ .suppress("class-reason")
+ .method(AsmClassFactory.method("m1").returns(INTERNAL_DESC).suppress("method-reason"))
+ .method(AsmClassFactory.method("m2").returns(INTERNAL_DESC)));
+
+ assertTrue(r.violations().isEmpty());
+ assertEquals(2, r.suppressions().size());
+ assertTrue(r.suppressions().stream().anyMatch(v ->
+ v.getMemberName().equals("m1") && v.getDescription().contains("reason: method-reason")));
+ assertTrue(r.suppressions().stream().anyMatch(v ->
+ v.getMemberName().equals("m2") && v.getDescription().contains("reason: class-reason")));
+ }
+
+ @Test
+ void suppressWithNoValue_recordsNoReasonGiven() throws IOException {
+ // @SuppressKafkaInternalApiUsage on its own (no value()) → ReasonCaptureVisitor records
+ // an empty reason, which the reporter renders as "(no reason given)".
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("leak").returns(INTERNAL_DESC).suppress(null)));
+
+ assertTrue(r.violations().isEmpty());
+ assertEquals(1, r.suppressions().size());
+ assertTrue(r.suppressions().get(0).getDescription().contains("reason: (no reason given)"),
+ "empty reason must render as '(no reason given)': "
+ + r.suppressions().get(0).getDescription());
+ }
+
+ @Test
+ void jarOfReturnsNull_classIsSilentlySkipped() throws IOException {
+ // Class is in the cascade iteration set but no jar is recorded for it. The validator
+ // bails on the missing jar without throwing — defensive against scan/cascade desync.
+ ClassFacts orphan = facts("org.apache.kafka.api.Orphan", ClassFacts.Flag.PUBLIC_API);
+ ApiSurface surface = ApiSurface.builder()
+ .addEffectivePublic(orphan)
+ .addEffectivePublic(orphan)
+ .build();
+
+ CheckResult r = CascadeValidator.validate(surface);
+ assertTrue(r.violations().isEmpty());
+ assertTrue(r.suppressions().isEmpty());
+ }
+
+ // ----- helpers -----
+
+ /** Owner class scaffolding shared by every cascade test: top-level public, audience @Public. */
+ private static AsmClassFactory.ClassBuilder owner() {
+ return AsmClassFactory.klass(OWNER_BIN).access(Opcodes.ACC_PUBLIC).publicApi();
+ }
+
+ private CheckResult run(AsmClassFactory.ClassBuilder owner) throws IOException {
+ return runWithExtras(owner);
+ }
+
+ /** Validate against a surface that registers {@code extras} in addition to the owner class. */
+ private CheckResult runWithExtras(AsmClassFactory.ClassBuilder owner, ClassFacts... extras) throws IOException {
+ File jar = TempJarBuilder.jar().addClass(owner).writeTo(tempDir, "x.jar");
+ ClassFacts ownerFacts = facts(owner.binaryName(), ClassFacts.Flag.PUBLIC_API);
+ ApiSurface.Builder b = ApiSurface.builder()
+ .recordClass(ownerFacts, jar)
+ .addEffectivePublic(ownerFacts)
+ .addEffectivePublic(ownerFacts);
+ for (ClassFacts f : extras) b.recordClass(f, jar);
+ return CascadeValidator.validate(b.build());
+ }
+
+ /** Validate against a surface where the named extras are also marked effectively public. */
+ private CheckResult runWithEffectivelyPublic(AsmClassFactory.ClassBuilder owner,
+ String... effectivelyPublicBinaryNames) throws IOException {
+ File jar = TempJarBuilder.jar().addClass(owner).writeTo(tempDir, "x.jar");
+ ClassFacts ownerFacts = facts(owner.binaryName(), ClassFacts.Flag.PUBLIC_API);
+ ApiSurface.Builder b = ApiSurface.builder()
+ .recordClass(ownerFacts, jar)
+ .addEffectivePublic(ownerFacts)
+ .addEffectivePublic(ownerFacts);
+ for (String name : effectivelyPublicBinaryNames) {
+ ClassFacts f = facts(name, ClassFacts.Flag.PUBLIC_API);
+ b.recordClass(f, jar).addEffectivePublic(f);
+ }
+ return CascadeValidator.validate(b.build());
+ }
+
+ private static ClassFacts facts(String binaryName, ClassFacts.Flag... flags) {
+ ClassFacts.Builder b = ClassFacts.builder(binaryName).sourceAccess(Opcodes.ACC_PUBLIC);
+ for (ClassFacts.Flag f : flags) b.addFlag(f);
+ return b.build();
+ }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/JavadocConsistencyValidatorTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/JavadocConsistencyValidatorTest.java
new file mode 100644
index 0000000000000..ebd186d942a49
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/JavadocConsistencyValidatorTest.java
@@ -0,0 +1,206 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class JavadocConsistencyValidatorTest {
+
+ private static final File DUMMY_JAR = new File("ignored");
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void directPublicMissingFromJavadoc_emitsMissingJavadoc() throws IOException {
+ ClassFacts bar = factsPublic("org.apache.kafka.foo.Bar");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(bar, DUMMY_JAR)
+ .addDirectPublic(bar)
+ .addEffectivePublic(bar)
+ .build();
+ File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+ assertEquals(1, result.violations().size());
+ PublicApiViolation v = result.violations().get(0);
+ assertEquals("MISSING_JAVADOC", v.getViolationType());
+ assertEquals("org.apache.kafka.foo.Bar", v.getClassName());
+ assertTrue(result.suppressions().isEmpty());
+ }
+
+ @Test
+ void htmlClassNotEffectivelyPublic_emitsMissingAnnotation() throws IOException {
+ // HTML claims a class is documented; the surface has nothing on it.
+ ApiSurface surface = ApiSurface.builder().build();
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("org/apache/kafka/foo/Sneaky.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+ assertEquals(1, result.violations().size());
+ PublicApiViolation v = result.violations().get(0);
+ assertEquals("MISSING_PUBLICAPI_ANNOTATION", v.getViolationType());
+ assertEquals("org.apache.kafka.foo.Sneaky", v.getClassName());
+ }
+
+ @Test
+ void effectivelyPublicViaInheritance_isNotFlagged() throws IOException {
+ // Inner has no direct @Public but inherits it from Outer. Real javadoc emits nested-class
+ // pages using the dotted form (Outer.Inner.html), which the validator must recognise.
+ ClassFacts outer = factsPublic("org.apache.kafka.foo.Outer");
+ ClassFacts inner = factsPlain("org.apache.kafka.foo.Outer$Inner");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(outer, DUMMY_JAR).recordClass(inner, DUMMY_JAR)
+ .addDirectPublic(outer)
+ .addEffectivePublic(outer).addEffectivePublic(inner)
+ .build();
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("org/apache/kafka/foo/Outer.html", "")
+ .addHtml("org/apache/kafka/foo/Outer.Inner.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+ assertTrue(result.violations().isEmpty(),
+ "perfect match (direct + inherited) should yield no violations: " + result.violations());
+ }
+
+ @Test
+ void deprecatedClassInJavadoc_isFilteredOut() throws IOException {
+ // OldClass is deprecated and not in directPublic — would normally trip MISSING_PUBLICAPI_ANNOTATION,
+ // but isDeprecated removes it from the HTML set first.
+ ClassFacts deprecated = factsBuilder("org.apache.kafka.foo.OldClass")
+ .addFlag(ClassFacts.Flag.DEPRECATED)
+ .build();
+ ApiSurface surface = ApiSurface.builder().recordClass(deprecated, DUMMY_JAR).build();
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("org/apache/kafka/foo/OldClass.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+ assertTrue(result.violations().isEmpty(),
+ "deprecated classes are out of scope on both validation sides");
+ }
+
+ @Test
+ void perfectMatch_noViolations() throws IOException {
+ ClassFacts bar = factsPublic("org.apache.kafka.foo.Bar");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(bar, DUMMY_JAR)
+ .addDirectPublic(bar)
+ .addEffectivePublic(bar)
+ .build();
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("org/apache/kafka/foo/Bar.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+ assertTrue(result.violations().isEmpty());
+ assertTrue(result.suppressions().isEmpty());
+ }
+
+ @Test
+ void structuralHtmlPages_areIgnored() throws IOException {
+ // Javadoc emits many non-class HTML files alongside class pages. The validator must skip
+ // them; otherwise every javadoc jar trips bogus MISSING_PUBLICAPI_ANNOTATION violations.
+ ApiSurface surface = ApiSurface.builder().build();
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("org/apache/kafka/foo/package-summary.html", "")
+ .addHtml("org/apache/kafka/foo/overview-tree.html", "")
+ .addHtml("org/apache/kafka/foo/index-all.html", "")
+ .addHtml("index.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+ assertTrue(result.violations().isEmpty(),
+ "structural HTML must not be misread as class pages: " + result.violations());
+ }
+
+ @Test
+ void nonKafkaHtml_isIgnored() throws IOException {
+ ApiSurface surface = ApiSurface.builder().build();
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("com/example/External.html", "")
+ .addHtml("java/util/HashMap.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+ assertTrue(result.violations().isEmpty());
+ }
+
+ @Test
+ void emptyJavadocJar_flagsAllDirectPublicClasses() throws IOException {
+ ClassFacts a = factsPublic("org.apache.kafka.foo.A");
+ ClassFacts b = factsPublic("org.apache.kafka.foo.B");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(a, DUMMY_JAR).recordClass(b, DUMMY_JAR)
+ .addDirectPublic(a).addDirectPublic(b)
+ .addEffectivePublic(a).addEffectivePublic(b)
+ .build();
+ File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+
+ assertEquals(2, result.violations().size());
+ assertTrue(result.violations().stream()
+ .allMatch(v -> "MISSING_JAVADOC".equals(v.getViolationType())));
+ }
+
+ @Test
+ void suppressionsListIsAlwaysEmpty_evenWithViolations() throws IOException {
+ // JavadocConsistencyValidator doesn't carry a suppression mechanism — the CheckResult
+ // always has an empty suppressions list. Callers compose validators uniformly, so the
+ // shape matters even when no suppressions are possible.
+ ClassFacts bar = factsPublic("org.apache.kafka.foo.Bar");
+ ApiSurface surface = ApiSurface.builder()
+ .recordClass(bar, DUMMY_JAR)
+ .addDirectPublic(bar)
+ .build();
+ File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = JavadocConsistencyValidator.validate(javadocJar, surface);
+ assertFalse(result.violations().isEmpty());
+ assertNotNull(result.suppressions());
+ assertTrue(result.suppressions().isEmpty());
+ }
+
+ private static ClassFacts factsPublic(String binaryName) {
+ return factsBuilder(binaryName).addFlag(ClassFacts.Flag.PUBLIC_API).build();
+ }
+
+ private static ClassFacts factsPlain(String binaryName) {
+ return factsBuilder(binaryName).build();
+ }
+
+ private static ClassFacts.Builder factsBuilder(String binaryName) {
+ return ClassFacts.builder(binaryName).sourceAccess(Opcodes.ACC_PUBLIC);
+ }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java
new file mode 100644
index 0000000000000..dee7f5aaef140
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java
@@ -0,0 +1,509 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PluginDeveloperApiUsageScannerTest {
+
+ @TempDir
+ Path tempDir;
+
+ @Test
+ void scan_noRoots_returnsEmpty() throws IOException {
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(true));
+ CheckResult result = scanner.scan(Collections.emptyList());
+ assertTrue(result.violations().isEmpty());
+ assertTrue(result.suppressions().isEmpty());
+ }
+
+ @Test
+ void scan_consumerReferencesPublicApiClass_returnsNoViolations() throws IOException {
+ File classFile = writeClassFile("com/example/PublicConsumer",
+ generateConsumerReferencing("com/example/PublicConsumer", "org/apache/kafka/clients/producer/KafkaProducer"));
+
+ Predicate isPublic = name -> "org.apache.kafka.clients.producer.KafkaProducer".equals(name);
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(isPublic);
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.isEmpty(),
+ "Reference to an @InterfaceAudience.Public class must not be reported, but got: " + violations);
+ }
+
+ @Test
+ void scan_consumerReferencesInternalApiClass_returnsViolation() throws IOException {
+ File classFile = writeClassFile("com/example/InternalConsumer",
+ generateConsumerReferencing("com/example/InternalConsumer", "org/apache/kafka/internals/SecretCabal"));
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertFalse(violations.isEmpty(), "Reference to an internal Kafka class must be reported");
+ PublicApiViolation v = violations.get(0);
+ assertEquals("INTERNAL_API_USAGE", v.getViolationType());
+ assertEquals("org.apache.kafka.internals.SecretCabal", v.getClassName());
+ assertTrue(v.getDescription().contains("com.example.InternalConsumer"),
+ "violation must name the consumer class. got: " + v.getDescription());
+ }
+
+ @Test
+ void scan_ignoresNonKafkaReferences() throws IOException {
+ File classFile = writeClassFile("com/example/JdkConsumer",
+ generateConsumerReferencing("com/example/JdkConsumer", "java/util/HashMap"));
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.isEmpty(),
+ "References to non-Kafka classes (e.g. JDK) must not be reported, got: " + violations);
+ }
+
+ @Test
+ void scan_classesPackagedInJar_areScanned() throws IOException {
+ byte[] internalBytes = generateConsumerReferencing(
+ "com/example/JarConsumer", "org/apache/kafka/internals/Hidden");
+ File jar = tempDir.resolve("consumer.jar").toFile();
+ try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jar))) {
+ jos.putNextEntry(new JarEntry("com/example/JarConsumer.class"));
+ jos.write(internalBytes);
+ jos.closeEntry();
+ }
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(jar)).violations();
+
+ assertFalse(violations.isEmpty(), "scan of jar should find the internal reference");
+ assertEquals("org.apache.kafka.internals.Hidden", violations.get(0).getClassName());
+ }
+
+ @Test
+ void scan_classWithKafkaFieldOfInternalType_returnsViolation() throws IOException {
+ // Different bytecode shape: a field whose type is an internal Kafka class.
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/example/FieldHolder", null, "java/lang/Object", null);
+ cw.visitField(Opcodes.ACC_PRIVATE, "secret", "Lorg/apache/kafka/internals/Hidden;", null, null).visitEnd();
+ // default ctor
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ cw.visitEnd();
+ File classFile = writeClassFile("com/example/FieldHolder", cw.toByteArray());
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "org.apache.kafka.internals.Hidden".equals(v.getClassName())),
+ "field-type reference must be reported, got: " + violations);
+ }
+
+ @Test
+ void scan_classLevelSuppressionAnnotation_skipsViolations() throws IOException {
+ byte[] bytes = generateConsumerReferencing(
+ "com/example/SuppressedClass", "org/apache/kafka/internals/Hidden",
+ ClassAnnotation.suppress("ports legacy adapter; tracked in JIRA-1234"),
+ MethodAnnotations.none());
+ File classFile = writeClassFile("com/example/SuppressedClass", bytes);
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ CheckResult result = scanner.scan(List.of(classFile.getParentFile()));
+
+ assertTrue(result.violations().isEmpty(),
+ "Class-level @SuppressKafkaInternalApiUsage must suppress all violations; got: " + result.violations());
+ assertFalse(result.suppressions().isEmpty(),
+ "suppressions list must record the skipped reference so it shows in the report");
+ PublicApiViolation s = result.suppressions().get(0);
+ assertEquals("SUPPRESSED_INTERNAL_API_USAGE", s.getViolationType());
+ assertTrue(s.getDescription().contains("ports legacy adapter; tracked in JIRA-1234"),
+ "suppression description must carry the annotation's reason; got: " + s.getDescription());
+ }
+
+ @Test
+ void scan_methodLevelSuppression_skipsOnlyThatMethod() throws IOException {
+ byte[] bytes = generateConsumerWithTwoMethods(
+ "com/example/PartiallySuppressed",
+ "org/apache/kafka/internals/Hidden",
+ MethodAnnotations.suppressOn("useIt", "intentional fallback"));
+ File classFile = writeClassFile("com/example/PartiallySuppressed", bytes);
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ // The other method ("alsoUseIt") is not suppressed -- it must still report.
+ assertEquals(1, violations.size(),
+ "method-level suppression must only affect that method; got: " + violations);
+ assertEquals("alsoUseIt", violations.get(0).getMemberName(),
+ "unsuppressed method should be the one reported");
+ }
+
+ @Test
+ void scan_suppressionWithoutReason_stillSuppresses() throws IOException {
+ byte[] bytes = generateConsumerReferencing(
+ "com/example/SuppressedNoReason", "org/apache/kafka/internals/Hidden",
+ ClassAnnotation.suppress(null), // annotation present, no value()
+ MethodAnnotations.none());
+ File classFile = writeClassFile("com/example/SuppressedNoReason", bytes);
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.isEmpty(),
+ "@SuppressKafkaInternalApiUsage with no reason must still suppress; got: " + violations);
+ }
+
+ @Test
+ void scan_nestedPrivateOverride_isFlagged_evenWhenOuterIsPublic() throws IOException {
+ // KIP-1265: a nested class explicitly marked @InterfaceAudience.Private must override an
+ // inherited @Public from its outer. The predicate is given the full nested name; a Private
+ // override must propagate through it, so a reference to org/apache/kafka/Outer$Inner is a
+ // violation even though Outer alone is Public.
+ File classFile = writeClassFile("com/example/NestedConsumer",
+ generateConsumerReferencing("com/example/NestedConsumer", "org/apache/kafka/Outer$Inner"));
+
+ // Outer is public, but the nested Outer$Inner is explicitly Private.
+ Predicate audience = name -> "org.apache.kafka.Outer".equals(name);
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(audience);
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertFalse(violations.isEmpty(),
+ "Reference to a Private-nested type must be flagged even when its outer is Public; got: " + violations);
+ assertEquals("org.apache.kafka.Outer$Inner", violations.get(0).getClassName());
+ }
+
+ @Test
+ void scan_tryCatchOnInternalException_isFlagged() throws IOException {
+ // `catch (InternalKafkaException ignored)` where the variable is never used leaves the
+ // only reference to the exception type in the exception-handler table — visitTryCatchBlock.
+ // Without that visitor override the scanner would miss it.
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, "com/example/CatchConsumer", null, "java/lang/Object", null);
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "doIt", "()V", null, null);
+ mv.visitCode();
+ org.objectweb.asm.Label start = new org.objectweb.asm.Label();
+ org.objectweb.asm.Label end = new org.objectweb.asm.Label();
+ org.objectweb.asm.Label handler = new org.objectweb.asm.Label();
+ org.objectweb.asm.Label after = new org.objectweb.asm.Label();
+ mv.visitTryCatchBlock(start, end, handler, "org/apache/kafka/internals/Boom");
+ mv.visitLabel(start);
+ // empty try body
+ mv.visitLabel(end);
+ mv.visitJumpInsn(Opcodes.GOTO, after);
+ mv.visitLabel(handler);
+ mv.visitInsn(Opcodes.POP);
+ mv.visitLabel(after);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ cw.visitEnd();
+
+ File classFile = writeClassFile("com/example/CatchConsumer", cw.toByteArray());
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "org.apache.kafka.internals.Boom".equals(v.getClassName())),
+ "exception-table entry must be reported; got: " + violations);
+ }
+
+ @Test
+ void scan_methodHeaderRefBuffering_returnTypeFlushedAtVisitCode() throws IOException {
+ // The method-header ref (return type from the descriptor) is buffered until
+ // visitCode fires — that's when the method-level @SuppressKafkaInternalApiUsage has
+ // been visited and we know the effective reason. A non-suppressed method must still
+ // produce the violation.
+ byte[] bytes = generateMethodWithInternalReturnType("com/example/HeaderRef",
+ "fetch", "Lorg/apache/kafka/internals/Hidden;", null);
+ File classFile = writeClassFile("com/example/HeaderRef", bytes);
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "org.apache.kafka.internals.Hidden".equals(v.getClassName())),
+ "method-header return-type ref must flush as a violation; got: " + violations);
+ }
+
+ @Test
+ void scan_abstractMethodHeaderRef_flushedAtVisitEnd() throws IOException {
+ // Abstract methods have no body, so ASM never fires visitCode. The scanner's visitEnd
+ // safety net must flush the buffered header refs anyway, otherwise an abstract method's
+ // internal return/param/exception types would silently slip through.
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+ "com/example/AbstractConsumer", null, "java/lang/Object", null);
+
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ // public abstract Hidden fetch(); — no body, no visitCode, no visitMaxs.
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC | Opcodes.ACC_ABSTRACT,
+ "fetch", "()Lorg/apache/kafka/internals/Hidden;", null, null);
+ mv.visitEnd();
+ cw.visitEnd();
+
+ File classFile = writeClassFile("com/example/AbstractConsumer", cw.toByteArray());
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ List violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "org.apache.kafka.internals.Hidden".equals(v.getClassName())),
+ "abstract-method header ref must be flushed at visitEnd; got: " + violations);
+ }
+
+ @Test
+ void scan_methodHeaderRefBuffering_methodLevelSuppressDivertsToSuppressions() throws IOException {
+ // Same method header as the previous test, but the method carries
+ // @SuppressKafkaInternalApiUsage. visitCode flushes the buffered header refs using the
+ // effective reason (method-level wins over class-level), so they land in suppressions.
+ byte[] bytes = generateMethodWithInternalReturnType("com/example/SuppressedHeader",
+ "fetch", "Lorg/apache/kafka/internals/Hidden;", "header-ref reason");
+ File classFile = writeClassFile("com/example/SuppressedHeader", bytes);
+
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(always(false));
+ CheckResult r = scanner.scan(List.of(classFile.getParentFile()));
+
+ assertTrue(r.violations().isEmpty(),
+ "method-level suppress must divert the buffered header ref; got violations: " + r.violations());
+ assertTrue(r.suppressions().stream().anyMatch(s -> s.getDescription().contains("reason: header-ref reason")),
+ "suppression list must carry the method's reason; got: " + r.suppressions());
+ }
+
+ /**
+ * Generate a class with a single method whose return type descriptor names an internal
+ * Kafka type. If {@code suppressReason} is non-null, the method carries
+ * {@code @SuppressKafkaInternalApiUsage(suppressReason)} (or no value when "").
+ */
+ private static byte[] generateMethodWithInternalReturnType(String consumerInternalName,
+ String methodName,
+ String returnDescriptor,
+ String suppressReason) {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, methodName, "()" + returnDescriptor, null, null);
+ if (suppressReason != null) {
+ AnnotationVisitor av = mv.visitAnnotation(SUPPRESS_DESC, true);
+ if (!suppressReason.isEmpty()) {
+ av.visit("value", suppressReason);
+ }
+ av.visitEnd();
+ }
+ mv.visitCode(); // triggers the header-ref flush in the scanner's visitor
+ mv.visitInsn(Opcodes.ACONST_NULL);
+ mv.visitInsn(Opcodes.ARETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ /** Build a class with a default ctor and a method that loads a class constant of {@code internalNameOfReferenced}. */
+ private static byte[] generateConsumerReferencing(String consumerInternalName, String internalNameOfReferenced) {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+ // default ctor
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ // public void useIt() { Class> c = .class; }
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "useIt", "()V", null, null);
+ mv.visitCode();
+ mv.visitLdcInsn(org.objectweb.asm.Type.getObjectType(internalNameOfReferenced));
+ mv.visitInsn(Opcodes.POP);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private File writeClassFile(String internalName, byte[] bytes) throws IOException {
+ File classFile = tempDir.resolve(internalName.replace('/', '_') + ".class").toFile();
+ Files.write(classFile.toPath(), bytes);
+ return classFile;
+ }
+
+ private static Predicate always(boolean value) {
+ return s -> value;
+ }
+
+ // --- suppression-aware test helpers -------------------------------------------------
+
+ private static final String SUPPRESS_DESC =
+ "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+
+ /** Class-level annotation descriptor — empty = no class annotation. */
+ private static final class ClassAnnotation {
+ final String reason; // null reason => annotation present with no value(); empty marker => no annotation
+ final boolean present;
+ private ClassAnnotation(boolean present, String reason) {
+ this.present = present;
+ this.reason = reason;
+ }
+ static ClassAnnotation none() {
+ return new ClassAnnotation(false, null);
+ }
+ static ClassAnnotation suppress(String reason) {
+ return new ClassAnnotation(true, reason);
+ }
+ }
+
+ /** Per-method suppression directives. */
+ private static final class MethodAnnotations {
+ final String suppressedMethod;
+ final String reason;
+ private MethodAnnotations(String suppressedMethod, String reason) {
+ this.suppressedMethod = suppressedMethod;
+ this.reason = reason;
+ }
+ static MethodAnnotations none() {
+ return new MethodAnnotations(null, null);
+ }
+ static MethodAnnotations suppressOn(String methodName, String reason) {
+ return new MethodAnnotations(methodName, reason);
+ }
+ }
+
+ /** Overload that also writes class-level + method-level suppression annotations. */
+ private static byte[] generateConsumerReferencing(String consumerInternalName,
+ String internalNameOfReferenced,
+ ClassAnnotation classAnnotation,
+ MethodAnnotations methodAnnotations) {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+ if (classAnnotation.present) {
+ AnnotationVisitor av = cw.visitAnnotation(SUPPRESS_DESC, true);
+ if (classAnnotation.reason != null) {
+ av.visit("value", classAnnotation.reason);
+ }
+ av.visitEnd();
+ }
+
+ // default ctor
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "useIt", "()V", null, null);
+ if ("useIt".equals(methodAnnotations.suppressedMethod)) {
+ AnnotationVisitor mav = mv.visitAnnotation(SUPPRESS_DESC, true);
+ if (methodAnnotations.reason != null) {
+ mav.visit("value", methodAnnotations.reason);
+ }
+ mav.visitEnd();
+ }
+ mv.visitCode();
+ mv.visitLdcInsn(org.objectweb.asm.Type.getObjectType(internalNameOfReferenced));
+ mv.visitInsn(Opcodes.POP);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ /** Two methods both referencing the same internal class, with a suppression on one. */
+ private static byte[] generateConsumerWithTwoMethods(String consumerInternalName,
+ String internalNameOfReferenced,
+ MethodAnnotations methodAnnotations) {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+ // default ctor
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ for (String methodName : List.of("useIt", "alsoUseIt")) {
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, methodName, "()V", null, null);
+ if (methodName.equals(methodAnnotations.suppressedMethod)) {
+ AnnotationVisitor mav = mv.visitAnnotation(SUPPRESS_DESC, true);
+ if (methodAnnotations.reason != null) {
+ mav.visit("value", methodAnnotations.reason);
+ }
+ mav.visitEnd();
+ }
+ mv.visitCode();
+ mv.visitLdcInsn(org.objectweb.asm.Type.getObjectType(internalNameOfReferenced));
+ mv.visitInsn(Opcodes.POP);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+}
diff --git a/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java
new file mode 100644
index 0000000000000..9ca4e05141799
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java
@@ -0,0 +1,216 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.Opcodes;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the {@link PublicApiChecker} facade: surface construction, {@code isPublicApi}
+ * predicate semantics, and merged results from {@link JavadocConsistencyValidator} +
+ * {@link CascadeValidator} + {@link PluginDeveloperApiUsageScanner}.
+ */
+class PublicApiCheckerTest {
+
+ @TempDir
+ Path tempDir;
+
+ // ----- isPublicApi -----
+
+ @Test
+ void isPublicApi_nonKafkaClass_returnsTrueOutOfScope() throws IOException {
+ PublicApiChecker checker = checkerFor();
+ assertTrue(checker.isPublicApi("java.util.Map"));
+ assertTrue(checker.isPublicApi("com.example.Foo"));
+ }
+
+ @Test
+ void isPublicApi_directPublic_returnsTrue() throws IOException {
+ PublicApiChecker checker = checkerFor(
+ AsmClassFactory.klass("org.apache.kafka.api.Pub").access(Opcodes.ACC_PUBLIC).publicApi());
+ assertTrue(checker.isPublicApi("org.apache.kafka.api.Pub"));
+ }
+
+ @Test
+ void isPublicApi_directPrivate_returnsFalse() throws IOException {
+ PublicApiChecker checker = checkerFor(
+ AsmClassFactory.klass("org.apache.kafka.api.Hidden").access(Opcodes.ACC_PUBLIC).privateApi());
+ assertFalse(checker.isPublicApi("org.apache.kafka.api.Hidden"));
+ }
+
+ @Test
+ void isPublicApi_deprecatedInternal_isStillFlagged() throws IOException {
+ // Consumer side: @Deprecated does NOT make an internal class out-of-scope.
+ // Deprecated-internal types are the most likely to be removed in the next release, so
+ // a consumer reference to one is exactly the kind of break the checker exists to catch.
+ // (The cascade validator still has its own deprecation bypass for the producer-side
+ // leak check, which is a separate policy.)
+ PublicApiChecker checker = checkerFor(
+ AsmClassFactory.klass("org.apache.kafka.api.Old")
+ .access(Opcodes.ACC_PUBLIC).privateApi().deprecated());
+ assertFalse(checker.isPublicApi("org.apache.kafka.api.Old"),
+ "consumer predicate must flag @Deprecated @Private — those are the highest-risk references");
+ }
+
+ @Test
+ void isPublicApi_nestedInheritsFromOuter() throws IOException {
+ PublicApiChecker checker = checkerFor(
+ AsmClassFactory.klass("org.apache.kafka.api.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+ AsmClassFactory.klass("org.apache.kafka.api.Outer$Inner").access(Opcodes.ACC_PUBLIC));
+ assertTrue(checker.isPublicApi("org.apache.kafka.api.Outer$Inner"));
+ }
+
+ @Test
+ void isPublicApi_nestedPrivateOverridesOuterPublic() throws IOException {
+ PublicApiChecker checker = checkerFor(
+ AsmClassFactory.klass("org.apache.kafka.api.Outer").access(Opcodes.ACC_PUBLIC).publicApi(),
+ AsmClassFactory.klass("org.apache.kafka.api.Outer$Inner").access(Opcodes.ACC_PUBLIC).privateApi());
+ assertFalse(checker.isPublicApi("org.apache.kafka.api.Outer$Inner"));
+ }
+
+ @Test
+ void isPublicApi_unknownKafkaClass_returnsFalse() throws IOException {
+ PublicApiChecker checker = checkerFor();
+ assertFalse(checker.isPublicApi("org.apache.kafka.UnknownClass"));
+ }
+
+ @Test
+ void isPublicApi_nestedWithoutOuterFacts_returnsFalse() throws IOException {
+ // Inner is recorded but Outer isn't. The chain walk finds Inner (no annotation), looks up
+ // Outer, gets null, exits with false — inheritance requires the outer to be in scope.
+ PublicApiChecker checker = checkerFor(
+ AsmClassFactory.klass("org.apache.kafka.api.Outer$Inner").access(Opcodes.ACC_PUBLIC));
+ assertFalse(checker.isPublicApi("org.apache.kafka.api.Outer$Inner"));
+ }
+
+ // ----- checkPublicApiConsistency -----
+
+ @Test
+ void checkPublicApiConsistency_mergesJavadocAndCascadeViolations() throws IOException {
+ // Bar is @Public with a method that leaks an internal type; javadoc jar is empty.
+ // Expected: MISSING_JAVADOC from the javadoc validator + INVALID_RETURN_TYPE from cascade.
+ AsmClassFactory.ClassBuilder bar = AsmClassFactory.klass("org.apache.kafka.api.Bar")
+ .access(Opcodes.ACC_PUBLIC).publicApi()
+ .method(AsmClassFactory.method("leak").returns("Lorg/apache/kafka/internals/Internal;"));
+ File projectJar = TempJarBuilder.jar().addClass(bar).writeTo(tempDir, "proj.jar");
+ File javadocJar = TempJarBuilder.jar().writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = new PublicApiChecker(List.of(projectJar)).checkPublicApiConsistency(javadocJar);
+
+ assertTrue(result.violations().stream().anyMatch(v -> "MISSING_JAVADOC".equals(v.getViolationType())),
+ "expected MISSING_JAVADOC from javadoc validator: " + result.violations());
+ assertTrue(result.violations().stream().anyMatch(v -> "INVALID_RETURN_TYPE".equals(v.getViolationType())),
+ "expected INVALID_RETURN_TYPE from cascade validator: " + result.violations());
+ }
+
+ @Test
+ void checkPublicApiConsistency_mergesSuppressionsFromCascade() throws IOException {
+ // Class-level @SuppressKafkaInternalApiUsage routes the cascade leak into suppressions
+ // instead of violations. Javadoc HTML present → no MISSING_JAVADOC.
+ AsmClassFactory.ClassBuilder bar = AsmClassFactory.klass("org.apache.kafka.api.Bar")
+ .access(Opcodes.ACC_PUBLIC).publicApi().suppress("legacy")
+ .method(AsmClassFactory.method("leak").returns("Lorg/apache/kafka/internals/Internal;"));
+ File projectJar = TempJarBuilder.jar().addClass(bar).writeTo(tempDir, "proj.jar");
+ File javadocJar = TempJarBuilder.jar()
+ .addHtml("org/apache/kafka/api/Bar.html", "")
+ .writeTo(tempDir, "javadoc.jar");
+
+ CheckResult result = new PublicApiChecker(List.of(projectJar)).checkPublicApiConsistency(javadocJar);
+
+ assertTrue(result.violations().isEmpty(), "everything suppressed: " + result.violations());
+ assertEquals(1, result.suppressions().size());
+ assertTrue(result.suppressions().get(0).getDescription().contains("reason: legacy"),
+ "suppression must carry the annotation reason: " + result.suppressions().get(0).getDescription());
+ }
+
+ // ----- checkBytecode -----
+
+ @Test
+ void checkBytecode_flagsConsumerReferenceToInternalClass() throws IOException {
+ // Project surface defines Hidden as an in-scope Kafka class with no @Public annotation.
+ // The consumer references it → checkBytecode should report INTERNAL_API_USAGE.
+ File projectJar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.internals.Hidden").access(Opcodes.ACC_PUBLIC))
+ .writeTo(tempDir, "proj.jar");
+ File consumerJar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("com.example.Consumer")
+ .access(Opcodes.ACC_PUBLIC)
+ .method(AsmClassFactory.method("hold").returns("Lorg/apache/kafka/internals/Hidden;")))
+ .writeTo(tempDir, "consumer.jar");
+
+ CheckResult result = new PublicApiChecker(List.of(projectJar)).checkBytecode(List.of(consumerJar));
+
+ assertFalse(result.violations().isEmpty(),
+ "unannotated Kafka class reference must be flagged: " + result.violations());
+ assertEquals("INTERNAL_API_USAGE", result.violations().get(0).getViolationType());
+ assertEquals("org.apache.kafka.internals.Hidden", result.violations().get(0).getClassName());
+ }
+
+ @Test
+ void checkBytecode_passesConsumerReferenceToPublicClass() throws IOException {
+ File projectJar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.api.Pub").access(Opcodes.ACC_PUBLIC).publicApi())
+ .writeTo(tempDir, "proj.jar");
+ File consumerJar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("com.example.Consumer")
+ .access(Opcodes.ACC_PUBLIC)
+ .method(AsmClassFactory.method("use").returns("Lorg/apache/kafka/api/Pub;")))
+ .writeTo(tempDir, "consumer.jar");
+
+ CheckResult result = new PublicApiChecker(List.of(projectJar)).checkBytecode(List.of(consumerJar));
+
+ assertTrue(result.violations().isEmpty(),
+ "reference to a @Public class must pass: " + result.violations());
+ }
+
+ // ----- constructor overload -----
+
+ @Test
+ void singleArgConstructor_equivalentToEmptyReferenceJars() throws IOException {
+ File projectJar = TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.api.Bar").access(Opcodes.ACC_PUBLIC).publicApi())
+ .writeTo(tempDir, "proj.jar");
+
+ PublicApiChecker single = new PublicApiChecker(List.of(projectJar));
+ PublicApiChecker dual = new PublicApiChecker(List.of(projectJar), Collections.emptyList());
+
+ assertTrue(single.isPublicApi("org.apache.kafka.api.Bar"));
+ assertTrue(dual.isPublicApi("org.apache.kafka.api.Bar"));
+ assertFalse(single.isPublicApi("org.apache.kafka.UnknownClass"));
+ assertFalse(dual.isPublicApi("org.apache.kafka.UnknownClass"));
+ }
+
+ // ----- helper -----
+
+ private PublicApiChecker checkerFor(AsmClassFactory.ClassBuilder... classes) throws IOException {
+ if (classes.length == 0) return new PublicApiChecker(List.of());
+ TempJarBuilder jar = TempJarBuilder.jar();
+ for (AsmClassFactory.ClassBuilder c : classes) jar.addClass(c);
+ return new PublicApiChecker(List.of(jar.writeTo(tempDir, "proj.jar")));
+ }
+}
diff --git a/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/AsmClassFactory.java b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/AsmClassFactory.java
new file mode 100644
index 0000000000000..d1df74baf2d34
--- /dev/null
+++ b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/AsmClassFactory.java
@@ -0,0 +1,366 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import org.objectweb.asm.AnnotationVisitor;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Synthesises {@code .class} bytes for KIP-1265 checker tests so they don't depend on real Kafka
+ * classes on the classpath. Carries enough knobs to exercise the audience-annotation, nested-class
+ * inner-access, and {@code @SuppressKafkaInternalApiUsage} paths the checker reads.
+ */
+public final class AsmClassFactory {
+
+ public static final String PUBLIC_API_DESC =
+ "Lorg/apache/kafka/common/annotation/InterfaceAudience$Public;";
+ public static final String PRIVATE_API_DESC =
+ "Lorg/apache/kafka/common/annotation/InterfaceAudience$Private;";
+ public static final String SUPPRESS_DESC =
+ "Lorg/apache/kafka/common/annotation/SuppressKafkaInternalApiUsage;";
+ public static final String DEPRECATED_DESC = "Ljava/lang/Deprecated;";
+
+ private AsmClassFactory() {}
+
+ public static ClassBuilder klass(String binaryName) {
+ return new ClassBuilder(binaryName);
+ }
+
+ public static MethodSpec method(String name) {
+ return new MethodSpec(name);
+ }
+
+ public static FieldSpec field(String name) {
+ return new FieldSpec(name);
+ }
+
+ /** Wrap an internal name ("org/apache/kafka/X") as an object type descriptor. */
+ public static String objDesc(String internalName) {
+ return "L" + internalName + ";";
+ }
+
+ public static String toInternal(String binaryName) {
+ return binaryName.replace('.', '/');
+ }
+
+ public static final class ClassBuilder {
+ private final String binaryName;
+ private int headerAccess = Opcodes.ACC_PUBLIC;
+ private Integer nestedAccess;
+ private String superInternal = "java/lang/Object";
+ private String[] interfaceInternals = new String[0];
+ private boolean isInterface;
+ private boolean publicApi;
+ private boolean privateApi;
+ private boolean deprecated;
+ private boolean hasSuppress;
+ private String suppressReason;
+ private final List methods = new ArrayList<>();
+ private final List fields = new ArrayList<>();
+
+ private ClassBuilder(String binaryName) {
+ this.binaryName = binaryName;
+ }
+
+ public String binaryName() {
+ return binaryName;
+ }
+
+ public ClassBuilder access(int access) {
+ this.headerAccess = access;
+ return this;
+ }
+
+ /**
+ * Add an {@code InnerClasses} attribute entry for this class with the given source-level
+ * access — the scanner reads this in preference to the (compiler-synthesised) header access
+ * for nested classes. Binary name must contain {@code $}.
+ */
+ public ClassBuilder nestedAccess(int access) {
+ this.nestedAccess = access;
+ return this;
+ }
+
+ public ClassBuilder superClass(String internalName) {
+ this.superInternal = internalName;
+ return this;
+ }
+
+ public ClassBuilder interfaces(String... internalNames) {
+ this.interfaceInternals = internalNames;
+ return this;
+ }
+
+ public ClassBuilder asInterface() {
+ this.isInterface = true;
+ return this;
+ }
+
+ public ClassBuilder publicApi() {
+ this.publicApi = true;
+ return this;
+ }
+
+ public ClassBuilder privateApi() {
+ this.privateApi = true;
+ return this;
+ }
+
+ public ClassBuilder deprecated() {
+ this.deprecated = true;
+ return this;
+ }
+
+ /** {@code @SuppressKafkaInternalApiUsage("reason")}; pass {@code null} to omit {@code value()}. */
+ public ClassBuilder suppress(String reason) {
+ this.hasSuppress = true;
+ this.suppressReason = reason;
+ return this;
+ }
+
+ public ClassBuilder method(MethodSpec method) {
+ this.methods.add(method);
+ return this;
+ }
+
+ public ClassBuilder field(FieldSpec field) {
+ this.fields.add(field);
+ return this;
+ }
+
+ public byte[] toBytes() {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_MAXS);
+ String internalName = toInternal(binaryName);
+ int access = headerAccess | (isInterface ? Opcodes.ACC_INTERFACE | Opcodes.ACC_ABSTRACT : 0);
+ cw.visit(Opcodes.V11, access, internalName, null,
+ isInterface ? "java/lang/Object" : superInternal, interfaceInternals);
+ writeClassAnnotations(cw);
+ writeInnerClassEntry(cw, internalName);
+ if (!isInterface) {
+ writeDefaultCtor(cw, superInternal);
+ }
+ for (FieldSpec f : fields) {
+ f.write(cw);
+ }
+ for (MethodSpec m : methods) {
+ m.write(cw);
+ }
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+
+ private void writeClassAnnotations(ClassWriter cw) {
+ if (publicApi) {
+ cw.visitAnnotation(PUBLIC_API_DESC, true).visitEnd();
+ }
+ if (privateApi) {
+ cw.visitAnnotation(PRIVATE_API_DESC, true).visitEnd();
+ }
+ if (deprecated) {
+ cw.visitAnnotation(DEPRECATED_DESC, true).visitEnd();
+ }
+ if (hasSuppress) {
+ writeSuppress(cw.visitAnnotation(SUPPRESS_DESC, true), suppressReason);
+ }
+ }
+
+ private void writeInnerClassEntry(ClassWriter cw, String internalName) {
+ if (nestedAccess == null) {
+ return;
+ }
+ int dollar = internalName.lastIndexOf('$');
+ if (dollar < 0) {
+ throw new IllegalStateException(
+ "nestedAccess requires a binaryName containing '$' (got " + binaryName + ")");
+ }
+ cw.visitInnerClass(internalName, internalName.substring(0, dollar),
+ internalName.substring(dollar + 1), nestedAccess);
+ }
+ }
+
+ public static final class MethodSpec {
+ private final String name;
+ private int access = Opcodes.ACC_PUBLIC;
+ private String returnDesc = "V";
+ private final List paramDescs = new ArrayList<>();
+ private final List exceptionInternals = new ArrayList<>();
+ private String signature;
+ private boolean hasSuppress;
+ private String suppressReason;
+
+ private MethodSpec(String name) {
+ this.name = name;
+ }
+
+ public MethodSpec access(int access) {
+ this.access = access;
+ return this;
+ }
+
+ public MethodSpec returns(String desc) {
+ this.returnDesc = desc;
+ return this;
+ }
+
+ public MethodSpec param(String desc) {
+ this.paramDescs.add(desc);
+ return this;
+ }
+
+ public MethodSpec throwsExc(String internalName) {
+ this.exceptionInternals.add(internalName);
+ return this;
+ }
+
+ /** Generic signature (JVMS §4.7.9), e.g. {@code "()Ljava/util/Map;"}. */
+ public MethodSpec signature(String signature) {
+ this.signature = signature;
+ return this;
+ }
+
+ public MethodSpec bridge() {
+ this.access |= Opcodes.ACC_BRIDGE | Opcodes.ACC_SYNTHETIC;
+ return this;
+ }
+
+ public MethodSpec synthetic() {
+ this.access |= Opcodes.ACC_SYNTHETIC;
+ return this;
+ }
+
+ public MethodSpec suppress(String reason) {
+ this.hasSuppress = true;
+ this.suppressReason = reason;
+ return this;
+ }
+
+ public void write(ClassWriter cw) {
+ String desc = "(" + String.join("", paramDescs) + ")" + returnDesc;
+ String[] excs = exceptionInternals.isEmpty()
+ ? null : exceptionInternals.toArray(new String[0]);
+ MethodVisitor mv = cw.visitMethod(access, name, desc, signature, excs);
+ if (hasSuppress) {
+ writeSuppress(mv.visitAnnotation(SUPPRESS_DESC, true), suppressReason);
+ }
+ mv.visitCode();
+ emitZeroReturn(mv, returnDesc);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+ }
+ }
+
+ public static final class FieldSpec {
+ private final String name;
+ private int access = Opcodes.ACC_PUBLIC;
+ private String typeDesc = "I";
+ private String signature;
+ private boolean hasSuppress;
+ private String suppressReason;
+
+ private FieldSpec(String name) {
+ this.name = name;
+ }
+
+ public FieldSpec access(int access) {
+ this.access = access;
+ return this;
+ }
+
+ public FieldSpec ofType(String desc) {
+ this.typeDesc = desc;
+ return this;
+ }
+
+ /** Generic signature (JVMS §4.7.9), e.g. {@code "Ljava/util/List;"}. */
+ public FieldSpec signature(String signature) {
+ this.signature = signature;
+ return this;
+ }
+
+ public FieldSpec suppress(String reason) {
+ this.hasSuppress = true;
+ this.suppressReason = reason;
+ return this;
+ }
+
+ public void write(ClassWriter cw) {
+ FieldVisitor fv = cw.visitField(access, name, typeDesc, signature, null);
+ if (hasSuppress) {
+ writeSuppress(fv.visitAnnotation(SUPPRESS_DESC, true), suppressReason);
+ }
+ fv.visitEnd();
+ }
+ }
+
+ private static void writeSuppress(AnnotationVisitor av, String reason) {
+ if (reason != null) {
+ av.visit("value", reason);
+ }
+ av.visitEnd();
+ }
+
+ private static void writeDefaultCtor(ClassWriter cw, String superInternal) {
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, superInternal, "", "()V", false);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(1, 1);
+ mv.visitEnd();
+ }
+
+ private static void emitZeroReturn(MethodVisitor mv, String desc) {
+ switch (desc.charAt(0)) {
+ case 'V':
+ mv.visitInsn(Opcodes.RETURN);
+ break;
+ case 'I':
+ case 'B':
+ case 'S':
+ case 'C':
+ case 'Z':
+ mv.visitInsn(Opcodes.ICONST_0);
+ mv.visitInsn(Opcodes.IRETURN);
+ break;
+ case 'J':
+ mv.visitInsn(Opcodes.LCONST_0);
+ mv.visitInsn(Opcodes.LRETURN);
+ break;
+ case 'F':
+ mv.visitInsn(Opcodes.FCONST_0);
+ mv.visitInsn(Opcodes.FRETURN);
+ break;
+ case 'D':
+ mv.visitInsn(Opcodes.DCONST_0);
+ mv.visitInsn(Opcodes.DRETURN);
+ break;
+ case 'L':
+ case '[':
+ mv.visitInsn(Opcodes.ACONST_NULL);
+ mv.visitInsn(Opcodes.ARETURN);
+ break;
+ default:
+ throw new IllegalArgumentException("Unsupported return descriptor: " + desc);
+ }
+ }
+}
diff --git a/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/TempJarBuilder.java b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/TempJarBuilder.java
new file mode 100644
index 0000000000000..bfa471cb0ffcb
--- /dev/null
+++ b/api-checker/core/src/testFixtures/java/org/apache/kafka/apicheck/TempJarBuilder.java
@@ -0,0 +1,89 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.apicheck;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+/**
+ * Builds a temp {@code .jar} with class-bytes and/or HTML entries — covers both jar shapes the
+ * checker consumes: project/reference jars (class bytes) and javadoc jars (HTML).
+ */
+public final class TempJarBuilder {
+
+ private final List entries = new ArrayList<>();
+
+ public static TempJarBuilder jar() {
+ return new TempJarBuilder();
+ }
+
+ private TempJarBuilder() {}
+
+ /** Add a class entry; jar path is derived from the binary name. */
+ public TempJarBuilder addClass(String binaryName, byte[] bytes) {
+ entries.add(new Entry(binaryName.replace('.', '/') + ".class", bytes));
+ return this;
+ }
+
+ /** Convenience: build the bytes from a {@link AsmClassFactory.ClassBuilder} and add them. */
+ public TempJarBuilder addClass(AsmClassFactory.ClassBuilder builder) {
+ return addClass(builder.binaryName(), builder.toBytes());
+ }
+
+ /** Add a javadoc HTML entry under the given jar-relative path; body may be empty. */
+ public TempJarBuilder addHtml(String entryPath, String body) {
+ entries.add(new Entry(entryPath, body.getBytes(StandardCharsets.UTF_8)));
+ return this;
+ }
+
+ /** Escape hatch for arbitrary entry paths (e.g. {@code module-info.class}, package-info, ...). */
+ public TempJarBuilder addEntry(String entryPath, byte[] bytes) {
+ entries.add(new Entry(entryPath, bytes));
+ return this;
+ }
+
+ public File writeTo(File jarFile) throws IOException {
+ try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+ for (Entry e : entries) {
+ jos.putNextEntry(new JarEntry(e.path));
+ jos.write(e.bytes);
+ jos.closeEntry();
+ }
+ }
+ return jarFile;
+ }
+
+ public File writeTo(Path tempDir, String fileName) throws IOException {
+ return writeTo(tempDir.resolve(fileName).toFile());
+ }
+
+ private static final class Entry {
+ final String path;
+ final byte[] bytes;
+ Entry(String path, byte[] bytes) {
+ this.path = path;
+ this.bytes = bytes;
+ }
+ }
+}
diff --git a/api-checker/gradle-plugins/build.gradle b/api-checker/gradle-plugins/build.gradle
new file mode 100644
index 0000000000000..d5aa722412b8d
--- /dev/null
+++ b/api-checker/gradle-plugins/build.gradle
@@ -0,0 +1,116 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+apply plugin: 'java-gradle-plugin'
+
+dependencies {
+ api project(':core')
+
+ testImplementation 'org.junit.jupiter:junit-jupiter:5.9.2'
+ testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.9.2'
+ testImplementation 'org.junit.platform:junit-platform-launcher:1.9.2'
+ testImplementation 'org.mockito:mockito-core:5.3.1'
+ testImplementation gradleTestKit()
+ // Re-use the ASM-based class-file builders from :core's test-fixtures (AsmClassFactory,
+ // TempJarBuilder) instead of duplicating them.
+ testImplementation(testFixtures(project(':core')))
+}
+
+gradlePlugin {
+ plugins {
+ kafkaPublicApiChecker {
+ id = 'org.apache.kafka.public-api-checker'
+ implementationClass = 'org.apache.kafka.gradle.KafkaPublicApiCheckerPlugin'
+ displayName = 'Kafka Public API Checker'
+ description = 'Internal plugin for checking public API consistency in Kafka codebase'
+ }
+ kafkaInternalApiChecker {
+ id = 'org.apache.kafka.internal-api-checker'
+ implementationClass = 'org.apache.kafka.gradle.KafkaInternalApiCheckerPlugin'
+ displayName = 'Kafka Internal API Checker'
+ description = 'Plugin for external projects to check they don\'t use internal Kafka APIs'
+ }
+ }
+}
+
+// The `java-gradle-plugin` plugin auto-generates one publication per declared plugin
+// (the marker poms) plus a `pluginMaven` publication carrying the implementation jar.
+// Rename the implementation jar to a sensible Maven coordinate; the markers point at it
+// by group + artifactId.
+// The `public-api-checker` plugin is Kafka-internal: it's applied to Kafka's own subprojects
+// via the includedBuild wiring, but shipping its marker pom to Maven Central would only
+// invite consumers to apply a plugin that expects Kafka-style javadoc layout. Disable the
+// publish tasks for that marker only — the plugin must stay registered in
+// `gradlePlugin { plugins { } }` above so `apply plugin: 'org.apache.kafka.public-api-checker'`
+// continues to resolve, but the marker artifact never reaches a remote repository.
+tasks.matching { it.name.startsWith('publishKafkaPublicApiCheckerPluginMarkerMavenPublication') }.configureEach {
+ enabled = false
+}
+
+afterEvaluate {
+ publishing.publications.matching { it.name == 'pluginMaven' }.configureEach {
+ artifactId = 'kafka-internal-api-checker-gradle-plugin'
+ pom {
+ name = 'Apache Kafka Internal API Checker Gradle Plugin'
+ description = 'Gradle plugin that flags references to non-@Public Kafka classes in a downstream project'
+ url = 'https://kafka.apache.org'
+ licenses {
+ license {
+ name = 'The Apache License, Version 2.0'
+ url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
+ }
+ }
+ developers {
+ developer {
+ id = 'apache-kafka'
+ name = 'Apache Kafka Team'
+ email = 'dev@kafka.apache.org'
+ }
+ }
+ scm {
+ connection = 'scm:git:https://github.com/apache/kafka.git'
+ developerConnection = 'scm:git:https://github.com/apache/kafka.git'
+ url = 'https://github.com/apache/kafka'
+ }
+ }
+ }
+ publishing.publications.matching { it.name.endsWith('PluginMarkerMaven') }.configureEach {
+ pom {
+ name = 'Apache Kafka Internal API Checker Gradle Plugin'
+ description = 'Gradle plugin marker resolving the Apache Kafka internal-API checker'
+ url = 'https://kafka.apache.org'
+ licenses {
+ license {
+ name = 'The Apache License, Version 2.0'
+ url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
+ }
+ }
+ developers {
+ developer {
+ id = 'apache-kafka'
+ name = 'Apache Kafka Team'
+ email = 'dev@kafka.apache.org'
+ }
+ }
+ scm {
+ connection = 'scm:git:https://github.com/apache/kafka.git'
+ developerConnection = 'scm:git:https://github.com/apache/kafka.git'
+ url = 'https://github.com/apache/kafka'
+ }
+ }
+ }
+}
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerExtension.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerExtension.java
new file mode 100644
index 0000000000000..f17a04c423a32
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerExtension.java
@@ -0,0 +1,118 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.gradle.api.Project;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+
+import java.io.File;
+
+/**
+ * Configuration extension for the KafkaInternalApiChecker plugin.
+ * This plugin is used by external projects to ensure they don't use internal Kafka APIs.
+ */
+public class KafkaInternalApiCheckerExtension {
+ private final Property enabled;
+ private final Property failOnViolation;
+ private final Property failOnNoKafkaDependency;
+ private final ConfigurableFileCollection classDirs;
+ private final ConfigurableFileCollection kafkaDependencyJars;
+ private final RegularFileProperty reportFile;
+
+ public KafkaInternalApiCheckerExtension(Project project) {
+ this.enabled = project.getObjects().property(Boolean.class);
+ this.enabled.convention(true);
+
+ this.failOnViolation = project.getObjects().property(Boolean.class);
+ this.failOnViolation.convention(true);
+
+ // Safety net for misconfigured projects. By default the task warns when it can't find
+ // any org.apache.kafka:* artifact on the classpath and proceeds with an empty surface
+ // (back-compat). Setting this to true turns that warning into a hard failure so a
+ // classpath or configuration mistake doesn't silently produce a "0 violations" report.
+ this.failOnNoKafkaDependency = project.getObjects().property(Boolean.class);
+ this.failOnNoKafkaDependency.convention(false);
+
+ this.classDirs = project.getObjects().fileCollection();
+ // Intentionally empty at construction. KafkaInternalApiCheckerPlugin reacts to the
+ // Java plugin being applied — see project.getPlugins().withType(JavaPlugin.class) — and
+ // adds sourceSets.main.output.classesDirs as a default contributor. That FileCollection
+ // carries the producer-task info for compileJava / compileScala / compileKotlin / …, so
+ // Gradle's @InputFiles validation can infer the compile-task dependency automatically.
+ // A raw project.file("build/classes") would scan the same bytecode but with no producer
+ // info attached, tripping "implicit dependency" validation errors on Gradle 9+.
+ //
+ // Users can still extend or replace this default from their build script:
+ // kafkaInternalApiChecker { classDirs.from(file("extra-classes")) } // extend
+ // kafkaInternalApiChecker { classDirs.setFrom(file("only-this")) } // replace
+
+ this.kafkaDependencyJars = project.getObjects().fileCollection();
+
+ this.reportFile = project.getObjects().fileProperty();
+ this.reportFile.convention(project.getLayout().getBuildDirectory().file("reports/kafka-internal-api-usage.txt"));
+ }
+
+ public Property getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled.set(enabled);
+ }
+
+ public Property getFailOnViolation() {
+ return failOnViolation;
+ }
+
+ public void setFailOnViolation(boolean failOnViolation) {
+ this.failOnViolation.set(failOnViolation);
+ }
+
+ public Property getFailOnNoKafkaDependency() {
+ return failOnNoKafkaDependency;
+ }
+
+ public void setFailOnNoKafkaDependency(boolean failOnNoKafkaDependency) {
+ this.failOnNoKafkaDependency.set(failOnNoKafkaDependency);
+ }
+
+ public ConfigurableFileCollection getClassDirs() {
+ return classDirs;
+ }
+
+ public void setClassDirs(Object... classDirs) {
+ this.classDirs.setFrom(classDirs);
+ }
+
+ public ConfigurableFileCollection getKafkaDependencyJars() {
+ return kafkaDependencyJars;
+ }
+
+ public void setKafkaDependencyJars(Object... jars) {
+ this.kafkaDependencyJars.setFrom(jars);
+ }
+
+ public RegularFileProperty getReportFile() {
+ return reportFile;
+ }
+
+ public void setReportFile(File reportFile) {
+ this.reportFile.set(reportFile);
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPlugin.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPlugin.java
new file mode 100644
index 0000000000000..4ec5a3b13bd1c
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPlugin.java
@@ -0,0 +1,127 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.artifacts.component.ModuleComponentIdentifier;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.api.tasks.SourceSetContainer;
+import org.gradle.api.tasks.TaskProvider;
+
+/**
+ * Gradle plugin for checking that external projects don't use internal Kafka APIs.
+ * This plugin is intended to be published and used by external Kafka plugin/application developers.
+ *
+ * Wiring strategy
+ *
+ * The plugin uses a reactive {@code Plugins.withType(JavaPlugin.class)} callback rather than
+ * {@code project.afterEvaluate(...)} for the source-set + lifecycle wiring. Two reasons:
+ *
+ *
+ * - Order-insensitive. {@code withType} fires whenever the Java plugin is applied,
+ * regardless of whether the user listed {@code id 'java'} before or after this plugin.
+ * Scala and Kotlin plugins apply JavaPlugin transitively, so the same callback also
+ * handles those.
+ * - Configuration-cache friendly. {@code afterEvaluate} forces eager evaluation
+ * at configuration time, which is at odds with the configuration cache and with
+ * Gradle's longer-term direction (isolated projects). The reactive form survives both.
+ *
+ *
+ * How {@code classDirs} is fed
+ *
+ * The extension's {@code classDirs} starts empty. When the Java plugin is applied, this plugin
+ * adds {@code sourceSets.main.output.classesDirs} as a contributor. That FileCollection
+ * carries producer-task info for the compile tasks, so Gradle's {@code @InputFiles} validation
+ * can infer the {@code compileJava} / {@code compileScala} / {@code compileKotlin} ordering
+ * automatically — no manual {@code dependsOn} required. Users can still call {@code .from(…)}
+ * on the extension to extend, or {@code .setFrom(…)} to replace.
+ *
+ * The task's {@code classDirs} is wired to the extension's {@code ConfigurableFileCollection}
+ * by reference at task-registration time. Because {@code Property.set(FileCollection)}
+ * stores the same reference, later {@code .from(...)} calls on the extension (including the
+ * main-source-set contribution made by the {@code withType} callback) are visible at task
+ * execution time.
+ */
+public class KafkaInternalApiCheckerPlugin implements Plugin {
+
+ /** Project property name that disables this checker for the current Gradle invocation. */
+ static final String SKIP_PROPERTY = "kafkaInternalApiChecker.skip";
+
+ @Override
+ public void apply(Project project) {
+ // Create the extension for configuration
+ KafkaInternalApiCheckerExtension extension = project.getExtensions()
+ .create("kafkaInternalApiChecker", KafkaInternalApiCheckerExtension.class, project);
+
+ if (KafkaPublicApiCheckerPlugin.isPropertyTruthy(project, SKIP_PROPERTY)) {
+ extension.getEnabled().set(false);
+ project.getLogger().lifecycle("Internal API checking disabled via -P{}", SKIP_PROPERTY);
+ }
+
+ // Register the task. classDirs is wired directly to the extension's ConfigurableFileCollection
+ // by reference, so anything added to extension.classDirs later (either by the JavaPlugin
+ // hook below or by the user's build script) is visible to the task at execution time.
+ TaskProvider taskProvider = project.getTasks()
+ .register("kafkaInternalApiChecker", KafkaInternalApiCheckerTask.class, task -> {
+ task.getCheckerEnabled().set(extension.getEnabled());
+ task.getFailOnViolation().set(extension.getFailOnViolation());
+ task.getFailOnNoKafkaDependency().set(extension.getFailOnNoKafkaDependency());
+ task.getClassDirs().set(extension.getClassDirs());
+ task.getKafkaDependencyJars().from(extension.getKafkaDependencyJars());
+ task.getReportFile().set(extension.getReportFile());
+ });
+
+ // Reactive hook: fires when the Java plugin (or any plugin that applies JavaPlugin
+ // transitively, e.g. Scala or Kotlin) becomes part of this project. See the class-level
+ // javadoc for why this is preferred over `project.afterEvaluate(...)`.
+ project.getPlugins().withType(JavaPlugin.class, plugin -> {
+ SourceSetContainer sourceSets = project.getExtensions().getByType(SourceSetContainer.class);
+
+ // Contribute main source set output as the default scanning target. Wrapped in a
+ // Provider so the underlying FileCollection (and its producer-task dependencies)
+ // is resolved lazily — important for configuration cache compatibility.
+ extension.getClassDirs().from(
+ sourceSets.named("main").map(s -> s.getOutput().getClassesDirs())
+ );
+
+ // Default kafkaDependencyJars: the org.apache.kafka-filtered artifact view of the
+ // project's compile classpaths. Wired through ArtifactView (not raw resolution) so
+ // (a) it's a declared task input — a Kafka version bump invalidates the task — and
+ // (b) resolution is deferred to execution time, which is configuration-cache safe.
+ extension.getKafkaDependencyJars().from(
+ kafkaArtifactsFromConfiguration(project, JavaPlugin.COMPILE_CLASSPATH_CONFIGURATION_NAME),
+ kafkaArtifactsFromConfiguration(project, JavaPlugin.TEST_COMPILE_CLASSPATH_CONFIGURATION_NAME)
+ );
+
+ // Lifecycle wiring: make `check` depend on our task. The 'check' task itself comes
+ // from LifecycleBasePlugin, which JavaPlugin pulls in.
+ project.getTasks().named("check").configure(checkTask -> checkTask.dependsOn(taskProvider));
+ });
+
+ project.getLogger().debug("Applied KafkaInternalApiChecker plugin to project: {}", project.getName());
+ }
+
+ private static org.gradle.api.file.FileCollection kafkaArtifactsFromConfiguration(Project project, String configurationName) {
+ return project.getConfigurations().getByName(configurationName)
+ .getIncoming()
+ .artifactView(view -> view.componentFilter(id ->
+ id instanceof ModuleComponentIdentifier
+ && "org.apache.kafka".equals(((ModuleComponentIdentifier) id).getGroup())))
+ .getFiles();
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTask.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTask.java
new file mode 100644
index 0000000000000..932e94d9f00e6
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTask.java
@@ -0,0 +1,190 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.apache.kafka.apicheck.PublicApiChecker;
+import org.apache.kafka.apicheck.PublicApiViolation;
+import org.apache.kafka.apicheck.CheckResult;
+import org.apache.kafka.apicheck.ViolationReporter;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.FileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFiles;
+import org.gradle.api.tasks.OutputFile;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.work.DisableCachingByDefault;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Gradle task for checking that external projects don't use internal Kafka APIs.
+ *
+ * Scans compiled bytecode (.class files) under the project's class output directories, so it
+ * works uniformly for Java, Scala, Kotlin and any other JVM-language consumer. The task
+ * therefore runs after the project's {@code classes} task.
+ */
+@DisableCachingByDefault(because = "Reports are tiny; caching the bytecode scan adds little")
+public class KafkaInternalApiCheckerTask extends DefaultTask {
+
+ private final Property enabled = getProject().getObjects().property(Boolean.class);
+ private final Property failOnViolation = getProject().getObjects().property(Boolean.class);
+ private final Property failOnNoKafkaDependency = getProject().getObjects().property(Boolean.class);
+ private final Property classDirs = getProject().getObjects().property(FileCollection.class);
+ private final ConfigurableFileCollection kafkaDependencyJars = getProject().getObjects().fileCollection();
+ private final RegularFileProperty reportFile = getProject().getObjects().fileProperty();
+
+ public KafkaInternalApiCheckerTask() {
+ setGroup("verification");
+ setDescription("Checks that compiled bytecode doesn't reference internal Kafka APIs");
+
+ // Set default values
+ enabled.convention(true);
+ failOnViolation.convention(true);
+ failOnNoKafkaDependency.convention(false);
+ classDirs.convention(getProject().files("build/classes"));
+ reportFile.convention(getProject().getLayout().getBuildDirectory().file("reports/kafka-internal-api-usage.txt"));
+ }
+
+ @TaskAction
+ public void checkInternalApiUsage() {
+ if (!getCheckerEnabled().get()) {
+ getLogger().info("KafkaInternalApiChecker is disabled, skipping...");
+ return;
+ }
+
+ FileCollection classes = classDirs.get();
+ if (classes.isEmpty()) {
+ getLogger().info("No class directories configured, skipping internal API check");
+ return;
+ }
+
+ getLogger().info("Checking for internal Kafka API usage in compiled bytecode...");
+
+ List kafkaJars = new ArrayList<>(kafkaDependencyJars.getFiles());
+ if (kafkaJars.isEmpty()) {
+ handleNoKafkaDependency();
+ return;
+ }
+
+ List classRoots = PublicApiChecker.collectExistingRoots(classes.getFiles());
+ if (classRoots.isEmpty()) {
+ getLogger().info("No class files found, skipping internal API check");
+ return;
+ }
+
+ runCheck(kafkaJars, classRoots);
+ }
+
+ private void handleNoKafkaDependency() {
+ String msg = "No org.apache.kafka:* dependencies found on the configured "
+ + "kafkaDependencyJars. The checker cannot derive an API surface and would "
+ + "produce a meaningless '0 violations' report — likely a classpath or "
+ + "configuration issue.";
+ if (failOnNoKafkaDependency.get()) {
+ throw new GradleException(msg);
+ }
+ getLogger().warn("{} Skipping internal API check. "
+ + "Set kafkaInternalApiChecker.failOnNoKafkaDependency = true to make this fatal.", msg);
+ }
+
+ private void runCheck(List kafkaJars, List classRoots) {
+ try {
+ getLogger().info("Scanning {} class file root(s) for internal API usage", classRoots.size());
+ CheckResult result = new PublicApiChecker(kafkaJars).checkBytecode(classRoots);
+ reportResults(result);
+ } catch (IOException e) {
+ throw new GradleException("Failed to check internal API usage: " + e.getMessage(), e);
+ }
+ }
+
+ private void reportResults(CheckResult result) throws IOException {
+ List violations = result.violations();
+ List suppressions = result.suppressions();
+
+ ViolationReporter reporter = new ViolationReporter();
+ File report = reportFile.get().getAsFile();
+ reporter.writeTextReport(violations, suppressions, report);
+ reporter.printToConsole(violations, suppressions, true);
+
+ getLogger().info("Internal API usage check completed. Report written to: {}", report.getAbsolutePath());
+
+ long unjustified = suppressions.stream().filter(PublicApiViolation::lacksReason).count();
+ if (unjustified > 0) {
+ getLogger().warn("{} suppression(s) carry no reason — KIP-1265 requires a justification on every @SuppressKafkaInternalApiUsage", unjustified);
+ }
+
+ if (violations.isEmpty()) {
+ getLogger().info("No internal API usage found.");
+ return;
+ }
+
+ String message = String.format("Found %d internal API usage violations. See report: %s",
+ violations.size(), report.getAbsolutePath());
+ if (failOnViolation.get()) {
+ throw new GradleException(message);
+ }
+ getLogger().warn(message);
+ }
+
+ @Input
+ public Property getCheckerEnabled() {
+ return enabled;
+ }
+
+ @Input
+ public Property getFailOnViolation() {
+ return failOnViolation;
+ }
+
+ @Input
+ public Property getFailOnNoKafkaDependency() {
+ return failOnNoKafkaDependency;
+ }
+
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public Property getClassDirs() {
+ return classDirs;
+ }
+
+ /**
+ * The Kafka jars whose {@code @InterfaceAudience.Public} annotations define the legal API
+ * surface. Declared as an {@code @InputFiles} so a Kafka version bump (i.e. a different jar
+ * path or content) invalidates the task and re-runs the scan. The plugin wires the default
+ * from the project's compile classpath, filtered to {@code org.apache.kafka}.
+ */
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public ConfigurableFileCollection getKafkaDependencyJars() {
+ return kafkaDependencyJars;
+ }
+
+ @OutputFile
+ public RegularFileProperty getReportFile() {
+ return reportFile;
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerExtension.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerExtension.java
new file mode 100644
index 0000000000000..6aca486d02802
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerExtension.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.gradle.api.Project;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+
+import java.io.File;
+
+/**
+ * Configuration extension for the KafkaPublicApiChecker plugin.
+ */
+public class KafkaPublicApiCheckerExtension {
+ private final Property enabled;
+ private final Property failOnViolation;
+ private final RegularFileProperty javadocJarPath;
+ private final ConfigurableFileCollection projectJarFiles;
+ private final ConfigurableFileCollection referenceJarFiles;
+ private final RegularFileProperty reportFile;
+
+ public KafkaPublicApiCheckerExtension(Project project) {
+ this.enabled = project.getObjects().property(Boolean.class);
+ this.enabled.convention(true);
+
+ this.failOnViolation = project.getObjects().property(Boolean.class);
+ this.failOnViolation.convention(true);
+
+ this.javadocJarPath = project.getObjects().fileProperty();
+
+ this.projectJarFiles = project.getObjects().fileCollection();
+
+ this.referenceJarFiles = project.getObjects().fileCollection();
+
+ this.reportFile = project.getObjects().fileProperty();
+ this.reportFile.convention(project.getLayout().getBuildDirectory().file("reports/kafka-public-api-checker.txt"));
+ }
+
+ public Property getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled.set(enabled);
+ }
+
+ public Property getFailOnViolation() {
+ return failOnViolation;
+ }
+
+ public void setFailOnViolation(boolean failOnViolation) {
+ this.failOnViolation.set(failOnViolation);
+ }
+
+ public RegularFileProperty getJavadocJarPath() {
+ return javadocJarPath;
+ }
+
+ public void setJavadocJarPath(File javadocJarPath) {
+ this.javadocJarPath.set(javadocJarPath);
+ }
+
+ public ConfigurableFileCollection getProjectJarFiles() {
+ return projectJarFiles;
+ }
+
+ /**
+ * Jars of sibling Kafka modules this project depends on. Their classes are merged into the
+ * scanned surface so cross-module {@code @InterfaceAudience.Public} references resolve, but
+ * they don't contribute to this module's own javadoc-consistency or cascade iteration.
+ */
+ public ConfigurableFileCollection getReferenceJarFiles() {
+ return referenceJarFiles;
+ }
+
+ public RegularFileProperty getReportFile() {
+ return reportFile;
+ }
+
+ public void setReportFile(File reportFile) {
+ this.reportFile.set(reportFile);
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerPlugin.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerPlugin.java
new file mode 100644
index 0000000000000..2774b56c2623e
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerPlugin.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.Plugin;
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.tasks.TaskProvider;
+import org.gradle.api.tasks.bundling.Jar;
+
+/**
+ * Gradle plugin for checking public API consistency in the Kafka codebase.
+ * This is an internal plugin that runs as part of Kafka's own build process.
+ */
+public class KafkaPublicApiCheckerPlugin implements Plugin {
+
+ /** Project property name that disables this checker for the current Gradle invocation. */
+ static final String SKIP_PROPERTY = "kafkaPublicApiChecker.skip";
+
+ @Override
+ public void apply(Project project) {
+ // Create the extension for configuration
+ KafkaPublicApiCheckerExtension extension = project.getExtensions()
+ .create("kafkaPublicApiChecker", KafkaPublicApiCheckerExtension.class, project);
+
+ if (isPropertyTruthy(project, SKIP_PROPERTY)) {
+ extension.getEnabled().set(false);
+ }
+
+ // Register the task
+ TaskProvider taskProvider = project.getTasks()
+ .register("kafkaPublicApiChecker", KafkaPublicApiCheckerTask.class, task -> {
+ task.getCheckerEnabled().set(extension.getEnabled());
+ task.getFailOnViolation().set(extension.getFailOnViolation());
+ // Intentionally NOT calling task.getJavadocJarPath().set(extension.getJavadocJarPath())
+ // here — `set(Provider)` marks the property as explicitly assigned even if the upstream
+ // is absent, which silently disables any later `.convention(...)` we install in
+ // afterEvaluate. The convention block below is the single source of truth.
+ task.getProjectJarFiles().from(extension.getProjectJarFiles());
+ task.getReferenceJarFiles().from(extension.getReferenceJarFiles());
+ task.getReportFile().set(extension.getReportFile());
+ });
+
+ // Configure task to run after javadoc
+ project.afterEvaluate(p -> {
+ TaskProvider docsJarTask = p.getTasks().named("docsJar", DefaultTask.class);
+
+ // Wire the checker's `javadocJarPath` input to the `javadocJar` task's `archiveFile`
+ // output where it exists. Reading the task's lazy output Provider — rather than
+ // recomputing the path from project.name + version — guarantees we read the *exact*
+ // file this Gradle run produced, which closes a stale-jar foot-gun:
+ //
+ // build/libs/kafka-foo-4.3.0-SNAPSHOT-javadoc.jar (left over from a months-old build)
+ // build/libs/kafka-foo-4.4.0-SNAPSHOT-javadoc.jar (just produced this run)
+ //
+ // The pre-fix auto-detect listed the directory and picked files[0], which on APFS
+ // returned the 4.3.0 (alphabetically earlier) jar. The checker then compared
+ // months-old javadoc against current bytecode and reported "all clean" while the
+ // module had real MISSING_PUBLICAPI_ANNOTATION violations against the fresh code.
+ Task javadocJarRaw = p.getTasks().findByName("javadocJar");
+ taskProvider.configure(task -> {
+ task.mustRunAfter(docsJarTask);
+ task.dependsOn(docsJarTask);
+
+ if (javadocJarRaw instanceof Jar) {
+ Jar javadocJar = (Jar) javadocJarRaw;
+ task.getJavadocJarPath().convention(
+ extension.getJavadocJarPath().orElse(javadocJar.getArchiveFile())
+ );
+ } else {
+ // No `javadocJar` task on this project — fall back to whatever the extension
+ // declared. The task's @TaskAction throws a clear error if neither is set,
+ // rather than silently scanning the wrong file.
+ task.getJavadocJarPath().convention(extension.getJavadocJarPath());
+ }
+ });
+
+ // Make javadoc task finalize with our checker
+ docsJarTask.configure(javadoc -> javadoc.finalizedBy(taskProvider));
+
+ // Add to check task dependencies if it exists
+ project.getTasks().matching(task -> task.getName().equals("check")).configureEach(checkTask -> {
+ checkTask.dependsOn(taskProvider);
+ });
+ });
+
+ if (isPropertyTruthy(project, SKIP_PROPERTY)) {
+ project.getLogger().lifecycle("Public API checking disabled via -P{}", SKIP_PROPERTY);
+ }
+
+ project.getLogger().debug("Applied KafkaPublicApiChecker plugin to project: {}", project.getName());
+ }
+
+ /** Treat property values "true", "1", "yes" (case-insensitive) — or an empty value (just {@code -PsomeProp}) — as true. */
+ static boolean isPropertyTruthy(Project project, String name) {
+ if (!project.hasProperty(name)) return false;
+ Object raw = project.findProperty(name);
+ if (raw == null) return true;
+ String s = raw.toString().trim();
+ return s.isEmpty() || "true".equalsIgnoreCase(s) || "1".equals(s) || "yes".equalsIgnoreCase(s);
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java
new file mode 100644
index 0000000000000..1b9a7885cb420
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java
@@ -0,0 +1,175 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.apache.kafka.apicheck.CheckResult;
+import org.apache.kafka.apicheck.PublicApiChecker;
+import org.apache.kafka.apicheck.PublicApiViolation;
+import org.apache.kafka.apicheck.ViolationReporter;
+
+import org.gradle.api.DefaultTask;
+import org.gradle.api.GradleException;
+import org.gradle.api.file.ConfigurableFileCollection;
+import org.gradle.api.file.RegularFileProperty;
+import org.gradle.api.provider.Property;
+import org.gradle.api.tasks.Input;
+import org.gradle.api.tasks.InputFile;
+import org.gradle.api.tasks.InputFiles;
+import org.gradle.api.tasks.Optional;
+import org.gradle.api.tasks.OutputFile;
+import org.gradle.api.tasks.PathSensitive;
+import org.gradle.api.tasks.PathSensitivity;
+import org.gradle.api.tasks.TaskAction;
+import org.gradle.work.DisableCachingByDefault;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Gradle task for checking public API consistency in Kafka codebase.
+ */
+@DisableCachingByDefault(because = "Reports are tiny; caching the bytecode scan adds little and complicates Hadoop-style audience-inheritance debugging")
+public class KafkaPublicApiCheckerTask extends DefaultTask {
+
+ private final Property enabled = getProject().getObjects().property(Boolean.class);
+ private final Property failOnViolation = getProject().getObjects().property(Boolean.class);
+ private final RegularFileProperty javadocJarPath = getProject().getObjects().fileProperty();
+ private final ConfigurableFileCollection projectJarFiles = getProject().getObjects().fileCollection();
+ private final ConfigurableFileCollection referenceJarFiles = getProject().getObjects().fileCollection();
+ private final RegularFileProperty reportFile = getProject().getObjects().fileProperty();
+
+ public KafkaPublicApiCheckerTask() {
+ setGroup("verification");
+ setDescription("Checks consistency between javadoc HTML files and @InterfaceAudience.Public annotations across project JARs");
+
+ // Set default values
+ enabled.convention(true);
+ failOnViolation.convention(true);
+ reportFile.convention(getProject().getLayout().getBuildDirectory().file("reports/kafka-public-api-checker.txt"));
+ }
+
+ @TaskAction
+ public void checkPublicApi() {
+ if (!getCheckerEnabled().get()) {
+ getLogger().info("KafkaPublicApiChecker is disabled, skipping...");
+ return;
+ }
+
+ File jarFile = getJavadocJarFile();
+ if (!jarFile.exists()) {
+ throw new GradleException("Javadoc JAR file not found: " + jarFile.getAbsolutePath() +
+ ". Make sure the javadoc task has run first.");
+ }
+
+ getLogger().info("Checking public API consistency in: {}", jarFile.getAbsolutePath());
+
+ try {
+ if (projectJarFiles.getFiles().isEmpty()) {
+ throw new GradleException(
+ "No project JARs configured on kafkaPublicApiChecker.projectJarFiles — "
+ + "the checker needs at least one classes/jar source to build the API surface.");
+ }
+ PublicApiChecker checker = new PublicApiChecker(
+ new ArrayList<>(projectJarFiles.getFiles()),
+ new ArrayList<>(referenceJarFiles.getFiles()));
+ CheckResult result = checker.checkPublicApiConsistency(jarFile);
+ List violations = result.violations();
+ List suppressions = result.suppressions();
+
+ // Generate report
+ ViolationReporter reporter = new ViolationReporter();
+ File report = reportFile.get().getAsFile();
+ reporter.writeTextReport(violations, suppressions, report);
+
+ // Print summary to console
+ reporter.printToConsole(violations, suppressions, true);
+
+ getLogger().info("Public API check completed. Report written to: {}", report.getAbsolutePath());
+
+ if (!suppressions.isEmpty()) {
+ getLogger().lifecycle("{} suppression(s) honoured — see report for justifications.", suppressions.size());
+ long unjustified = suppressions.stream().filter(PublicApiViolation::lacksReason).count();
+ if (unjustified > 0) {
+ getLogger().warn("{} suppression(s) carry no reason — KIP-1265 requires a justification on every @SuppressKafkaInternalApiUsage", unjustified);
+ }
+ }
+ if (!violations.isEmpty()) {
+ String message = String.format("Found %d public API violations. See report: %s",
+ violations.size(), report.getAbsolutePath());
+
+ if (failOnViolation.get()) {
+ throw new GradleException(message);
+ } else {
+ getLogger().warn(message);
+ }
+ } else {
+ getLogger().info("No public API violations found.");
+ }
+
+ } catch (IOException e) {
+ throw new GradleException("Failed to check public API: " + e.getMessage(), e);
+ }
+ }
+
+ private File getJavadocJarFile() {
+ if (!javadocJarPath.isPresent()) {
+ throw new GradleException("kafkaPublicApiChecker.javadocJarPath is not set. "
+ + "Either configure it explicitly on the extension, or apply this plugin to a "
+ + "project that defines a 'javadocJar' Jar task whose output the plugin can "
+ + "wire automatically.");
+ }
+ return javadocJarPath.get().getAsFile();
+ }
+
+ @Input
+ public Property getCheckerEnabled() {
+ return enabled;
+ }
+
+ @Input
+ public Property getFailOnViolation() {
+ return failOnViolation;
+ }
+
+ @InputFile
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public RegularFileProperty getJavadocJarPath() {
+ return javadocJarPath;
+ }
+
+ @InputFiles
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public ConfigurableFileCollection getProjectJarFiles() {
+ return projectJarFiles;
+ }
+
+ @InputFiles
+ @Optional
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public ConfigurableFileCollection getReferenceJarFiles() {
+ return referenceJarFiles;
+ }
+
+ @OutputFile
+ public RegularFileProperty getReportFile() {
+ return reportFile;
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPluginTest.java b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPluginTest.java
new file mode 100644
index 0000000000000..b90bb63c4b438
--- /dev/null
+++ b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerPluginTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.gradle.api.Project;
+import org.gradle.api.Task;
+import org.gradle.api.plugins.JavaPlugin;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Plugin-wiring coverage for the consumer-facing {@code internal-api-checker}. The actual scan
+ * logic is covered by {@code PluginDeveloperApiUsageScannerTest}; this class verifies that
+ * applying the plugin produces a working extension/task and that the JavaPlugin reactive hook
+ * contributes the expected defaults (classDirs + kafkaDependencyJars + `check` dependency).
+ */
+class KafkaInternalApiCheckerPluginTest {
+
+ @Test
+ void pluginApply_createsExtensionAndTask() {
+ Project project = ProjectBuilder.builder().build();
+ project.getPlugins().apply(KafkaInternalApiCheckerPlugin.class);
+
+ assertNotNull(project.getExtensions().findByName("kafkaInternalApiChecker"),
+ "expected the extension to be registered");
+ assertNotNull(project.getTasks().findByName("kafkaInternalApiChecker"),
+ "expected the kafkaInternalApiChecker task to be registered");
+ }
+
+ @Test
+ void skipProperty_disablesTheChecker() {
+ // -PkafkaInternalApiChecker.skip flips extension.enabled to false at configure time
+ // (replacement for the old execution-phase skipInternalApiCheck task, whose doLast
+ // mutation never reliably propagated).
+ Project project = ProjectBuilder.builder().build();
+ project.getExtensions().getExtraProperties().set("kafkaInternalApiChecker.skip", "true");
+ project.getPlugins().apply(KafkaInternalApiCheckerPlugin.class);
+
+ KafkaInternalApiCheckerExtension extension = (KafkaInternalApiCheckerExtension)
+ project.getExtensions().getByName("kafkaInternalApiChecker");
+ assertFalse(extension.getEnabled().get(),
+ "extension.enabled must flip to false when the skip property is set");
+ }
+
+ @Test
+ void pluginApplyWithJavaPlugin_wiresClassDirsKafkaJarsAndCheckLifecycle() {
+ Project project = ProjectBuilder.builder().build();
+ project.getPlugins().apply(KafkaInternalApiCheckerPlugin.class);
+ project.getPlugins().apply(JavaPlugin.class);
+
+ KafkaInternalApiCheckerExtension extension = (KafkaInternalApiCheckerExtension)
+ project.getExtensions().getByName("kafkaInternalApiChecker");
+ KafkaInternalApiCheckerTask task = (KafkaInternalApiCheckerTask)
+ project.getTasks().getByName("kafkaInternalApiChecker");
+
+ // classDirs default: sourceSets.main.output.classesDirs contributed by the reactive hook.
+ // Resolving via getFiles() walks the contributors lazily.
+ assertTrue(extension.getClassDirs().getFiles().stream()
+ .anyMatch(f -> f.getPath().contains("classes")),
+ "JavaPlugin hook should contribute main source set output to classDirs, got: "
+ + extension.getClassDirs().getFiles());
+
+ // The new declared input. With no Kafka deps on the test project's classpath the filter
+ // produces an empty file collection; what matters is that the file collection itself is
+ // wired (not null) so version bumps can later invalidate the task.
+ assertNotNull(task.getKafkaDependencyJars(),
+ "task.getKafkaDependencyJars() must be declared and wired by the plugin");
+
+ // Lifecycle: kafkaInternalApiChecker must be a dependency of `check`.
+ Task checkTask = project.getTasks().getByName("check");
+ assertTrue(checkTask.getTaskDependencies().getDependencies(checkTask).stream()
+ .anyMatch(d -> "kafkaInternalApiChecker".equals(d.getName())),
+ "`check` task should depend on `kafkaInternalApiChecker`");
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTaskTest.java b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTaskTest.java
new file mode 100644
index 0000000000000..cc0bf8663f35e
--- /dev/null
+++ b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaInternalApiCheckerTaskTest.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.apache.kafka.apicheck.AsmClassFactory;
+import org.apache.kafka.apicheck.TempJarBuilder;
+
+import org.gradle.api.GradleException;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.objectweb.asm.ClassWriter;
+import org.objectweb.asm.MethodVisitor;
+import org.objectweb.asm.Opcodes;
+import org.objectweb.asm.Type;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+/**
+ * End-to-end coverage of the consumer Gradle path: feeds a {@link KafkaInternalApiCheckerTask}
+ * a synthetic Kafka surface jar + synthetic consumer bytecode and runs the task action directly.
+ *
+ * The scanner itself is unit-tested in {@code PluginDeveloperApiUsageScannerTest}; what this
+ * class adds is the wiring between the task's declared {@code kafkaDependencyJars} /
+ * {@code classDirs} inputs, the {@code @TaskAction}, and the GradleException-vs-warning routing
+ * via {@code failOnViolation}.
+ */
+class KafkaInternalApiCheckerTaskTest {
+
+ @TempDir
+ Path tempDir;
+
+ private Project project;
+ private KafkaInternalApiCheckerTask task;
+
+ @BeforeEach
+ void setUp() {
+ project = ProjectBuilder.builder().build();
+ task = project.getTasks().create("kafkaInternalApiChecker", KafkaInternalApiCheckerTask.class);
+ }
+
+ @Test
+ void disabled_returnsWithoutScanning() {
+ task.getCheckerEnabled().set(false);
+ assertDoesNotThrow(() -> task.checkInternalApiUsage());
+ }
+
+ @Test
+ void emptyKafkaDependencyJars_skipsCheckWithWarning() throws IOException {
+ // classDirs configured, but no Kafka jars on the classpath — the scanner has no public
+ // surface to validate against, so the task warns and returns rather than treating every
+ // org.apache.kafka.* reference in the consumer bytecode as a violation.
+ task.getClassDirs().set(project.files(consumerClassesDir("org/apache/kafka/internals/Hidden")));
+ assertDoesNotThrow(() -> task.checkInternalApiUsage());
+ }
+
+ @Test
+ void consumerReferencingInternalKafkaType_failsWhenFailOnViolation() throws IOException {
+ File kafkaJar = synthesisedKafkaJarWithPublicClass();
+ File classesDir = consumerClassesDir("org/apache/kafka/internals/Hidden");
+
+ task.getKafkaDependencyJars().from(kafkaJar);
+ task.getClassDirs().set(project.files(classesDir));
+ task.getFailOnViolation().set(true);
+
+ GradleException ex = assertThrows(GradleException.class, () -> task.checkInternalApiUsage());
+ org.junit.jupiter.api.Assertions.assertTrue(ex.getMessage().contains("internal API usage violations"),
+ "expected violations message, got: " + ex.getMessage());
+ }
+
+ @Test
+ void consumerReferencingInternalKafkaType_warnsWhenFailOnViolationFalse() throws IOException {
+ File kafkaJar = synthesisedKafkaJarWithPublicClass();
+ File classesDir = consumerClassesDir("org/apache/kafka/internals/Hidden");
+
+ task.getKafkaDependencyJars().from(kafkaJar);
+ task.getClassDirs().set(project.files(classesDir));
+ task.getFailOnViolation().set(false);
+
+ // Logs a warning but does not throw — useful for adopt-as-warning rollouts.
+ assertDoesNotThrow(() -> task.checkInternalApiUsage());
+ }
+
+ @Test
+ void consumerReferencingPublicKafkaType_passes() throws IOException {
+ File kafkaJar = synthesisedKafkaJarWithPublicClass();
+ // Consumer references the @Public type rather than the internal one.
+ File classesDir = consumerClassesDir("org/apache/kafka/clients/producer/KafkaProducer");
+
+ task.getKafkaDependencyJars().from(kafkaJar);
+ task.getClassDirs().set(project.files(classesDir));
+ task.getFailOnViolation().set(true);
+
+ assertDoesNotThrow(() -> task.checkInternalApiUsage());
+ }
+
+ // ----- helpers -----
+
+ /**
+ * Produce a "kafka-clients-like" jar containing one {@code @InterfaceAudience.Public}
+ * class. References to that class from a consumer should pass; references to any other
+ * {@code org.apache.kafka.*} class should be flagged as internal.
+ */
+ private File synthesisedKafkaJarWithPublicClass() throws IOException {
+ return TempJarBuilder.jar()
+ .addClass(AsmClassFactory.klass("org.apache.kafka.clients.producer.KafkaProducer")
+ .access(Opcodes.ACC_PUBLIC)
+ .publicApi())
+ .writeTo(tempDir, "kafka-clients.jar");
+ }
+
+ /**
+ * Write a single consumer .class to a fresh directory; the class loads a constant of the
+ * given internal-name type, which the bytecode scanner picks up as a usage reference.
+ */
+ private File consumerClassesDir(String referencedInternalName) throws IOException {
+ File dir = Files.createTempDirectory(tempDir, "consumer-classes").toFile();
+ Files.write(new File(dir, "Consumer.class").toPath(),
+ consumerBytes("com/example/Consumer", referencedInternalName));
+ return dir;
+ }
+
+ /** Default ctor + one method that loads a class constant of {@code referencedInternalName}. */
+ private static byte[] consumerBytes(String consumerInternalName, String referencedInternalName) {
+ ClassWriter cw = new ClassWriter(ClassWriter.COMPUTE_FRAMES | ClassWriter.COMPUTE_MAXS);
+ cw.visit(Opcodes.V11, Opcodes.ACC_PUBLIC, consumerInternalName, null, "java/lang/Object", null);
+
+ MethodVisitor ctor = cw.visitMethod(Opcodes.ACC_PUBLIC, "", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ MethodVisitor mv = cw.visitMethod(Opcodes.ACC_PUBLIC, "useIt", "()V", null, null);
+ mv.visitCode();
+ mv.visitLdcInsn(Type.getObjectType(referencedInternalName));
+ mv.visitInsn(Opcodes.POP);
+ mv.visitInsn(Opcodes.RETURN);
+ mv.visitMaxs(0, 0);
+ mv.visitEnd();
+
+ cw.visitEnd();
+ return cw.toByteArray();
+ }
+}
\ No newline at end of file
diff --git a/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTaskTest.java b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTaskTest.java
new file mode 100644
index 0000000000000..43024fbf1a864
--- /dev/null
+++ b/api-checker/gradle-plugins/src/test/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTaskTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.gradle;
+
+import org.gradle.api.GradleException;
+import org.gradle.api.Project;
+import org.gradle.testfixtures.ProjectBuilder;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Path;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Tests for the enhanced KafkaPublicApiCheckerTask with dual validation.
+ */
+public class KafkaPublicApiCheckerTaskTest {
+
+ @TempDir
+ Path tempDir;
+
+ private Project project;
+ private KafkaPublicApiCheckerTask task;
+
+ @BeforeEach
+ void setUp() {
+ project = ProjectBuilder.builder().build();
+ task = project.getTasks().create("checkPublicApi", KafkaPublicApiCheckerTask.class);
+ }
+
+ @Test
+ void testTaskConfiguration_DefaultValues() {
+ assertTrue(task.getCheckerEnabled().get());
+ assertTrue(task.getFailOnViolation().get());
+ }
+
+ @Test
+ void testTaskConfiguration_CustomValues() {
+ task.getCheckerEnabled().set(false);
+ task.getFailOnViolation().set(false);
+
+ assertFalse(task.getCheckerEnabled().get());
+ assertFalse(task.getFailOnViolation().get());
+ }
+
+ @Test
+ void testTaskExecution_DisabledChecker() {
+ task.getCheckerEnabled().set(false);
+
+ // Should not throw exception when disabled
+ assertDoesNotThrow(() -> task.checkPublicApi());
+ }
+
+ @Test
+ void testTaskExecution_MissingJavadocJar() {
+ task.getCheckerEnabled().set(true);
+ task.getJavadocJarPath().set(new File("nonexistent.jar"));
+
+ GradleException exception = assertThrows(GradleException.class, () -> task.checkPublicApi());
+ assertTrue(exception.getMessage().contains("Javadoc JAR file not found"));
+ }
+
+ @Test
+ void testTaskExecution_WithValidJarFiles() throws IOException {
+ File javadocJar = createMockJavadocJar();
+ File projectJar = createMockProjectJar();
+
+ task.getCheckerEnabled().set(true);
+ task.getJavadocJarPath().set(javadocJar);
+ task.getProjectJarFiles().from(projectJar);
+ task.getFailOnViolation().set(false); // Don't fail on violations for this test
+
+ // Should not throw exception
+ assertDoesNotThrow(() -> task.checkPublicApi());
+ }
+
+ @Test
+ void testTaskExecution_FailsWhenProjectJarsMissing() throws IOException {
+ // Project JAR files are mandatory after the SOLID refactor — without them the scanner has
+ // nothing to read. The task must surface this as a configuration error rather than silently
+ // running on an empty surface (which would let everything appear "missing javadoc").
+ File javadocJar = createMockJavadocJar();
+
+ task.getCheckerEnabled().set(true);
+ task.getJavadocJarPath().set(javadocJar);
+ task.getFailOnViolation().set(false);
+
+ assertThrows(org.gradle.api.GradleException.class, () -> task.checkPublicApi());
+ }
+
+ @Test
+ void testTaskInputsOutputs() throws IOException {
+ File javadocJar = createMockJavadocJar();
+ File projectJar = createMockProjectJar();
+
+ task.getJavadocJarPath().set(javadocJar);
+ task.getProjectJarFiles().from(projectJar);
+
+ // Verify inputs are configured
+ assertNotNull(task.getJavadocJarPath().getOrNull());
+ assertFalse(task.getProjectJarFiles().isEmpty());
+
+ // Verify outputs are configured
+ assertNotNull(task.getReportFile().getOrNull());
+ }
+
+ @Test
+ void testTaskDescription() {
+ assertEquals("Checks consistency between javadoc HTML files and @InterfaceAudience.Public annotations across project JARs",
+ task.getDescription());
+ }
+
+ @Test
+ void testTaskGroup() {
+ assertEquals("verification", task.getGroup());
+ }
+
+ // Helper methods for creating mock JAR files
+
+ private File createMockJavadocJar() throws IOException {
+ File jarFile = tempDir.resolve("test-javadoc.jar").toFile();
+
+ try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+ // Add valid class HTML files
+ addHtmlEntry(jos, "org/apache/kafka/common/Resource.html",
+ "Resource class documentation");
+ addHtmlEntry(jos, "org/apache/kafka/clients/producer/Producer.html",
+ "Producer interface documentation");
+
+ // Add structural HTML files
+ addHtmlEntry(jos, "index.html", "Index");
+ addHtmlEntry(jos, "overview-tree.html", "Tree");
+ }
+
+ return jarFile;
+ }
+
+ private File createMockProjectJar() throws IOException {
+ File jarFile = tempDir.resolve("test-project.jar").toFile();
+
+ try (JarOutputStream jos = new JarOutputStream(new FileOutputStream(jarFile))) {
+ // Add some empty entries instead of invalid class files
+ addEntry(jos, "META-INF/MANIFEST.MF", "Manifest-Version: 1.0\n".getBytes());
+ }
+
+ return jarFile;
+ }
+
+ private void addHtmlEntry(JarOutputStream jos, String path, String content) throws IOException {
+ JarEntry entry = new JarEntry(path);
+ jos.putNextEntry(entry);
+ jos.write(content.getBytes());
+ jos.closeEntry();
+ }
+
+ private void addEntry(JarOutputStream jos, String path, byte[] content) throws IOException {
+ JarEntry entry = new JarEntry(path);
+ jos.putNextEntry(entry);
+ jos.write(content);
+ jos.closeEntry();
+ }
+}
\ No newline at end of file
diff --git a/api-checker/maven-plugin/build.gradle b/api-checker/maven-plugin/build.gradle
new file mode 100644
index 0000000000000..9508d830d3bea
--- /dev/null
+++ b/api-checker/maven-plugin/build.gradle
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+// Maven mojo for the KIP-1265 internal-api-checker. Lives in its own subproject so the
+// Maven runtime dependencies (maven-plugin-api, maven-core, …) stay off the Gradle plugin's
+// classpath, and so the published Gradle jar no longer carries Maven mojo classes.
+
+// Single source of truth for the Maven dependency versions. The version block below and the
+// hand-maintained plugin.xml descriptor both read these via processResources templating, so
+// the two can never drift.
+def mavenApiVersion = '3.9.4'
+def mavenPluginAnnotationsVersion = '3.9.0'
+
+dependencies {
+ api project(':core')
+
+ implementation "org.apache.maven:maven-plugin-api:${mavenApiVersion}"
+ implementation "org.apache.maven:maven-core:${mavenApiVersion}"
+ implementation "org.apache.maven:maven-artifact:${mavenApiVersion}"
+ implementation "org.apache.maven.plugin-tools:maven-plugin-annotations:${mavenPluginAnnotationsVersion}"
+}
+
+// Template plugin.xml at packaging time so its and the Maven dep versions resolve
+// from the same source as the published artifact / dependency block above. Ant's @token@
+// delimiter is intentional — Maven's runtime substitutions inside plugin.xml use ${...},
+// so the @ delimiter can never collide.
+processResources {
+ filesMatching('META-INF/maven/plugin.xml') {
+ filter(org.apache.tools.ant.filters.ReplaceTokens, tokens: [
+ version: project.version.toString(),
+ mavenApiVersion: mavenApiVersion,
+ mavenPluginAnnotationsVersion: mavenPluginAnnotationsVersion,
+ ])
+ }
+}
+
+publishing {
+ publications {
+ mavenPlugin(MavenPublication) {
+ artifactId = 'kafka-internal-api-checker-maven-plugin'
+ from components.java
+
+ pom {
+ name = 'Apache Kafka Internal API Checker Maven Plugin'
+ description = 'Maven plugin that flags references to non-@Public Kafka classes in a downstream project'
+ url = 'https://kafka.apache.org'
+ packaging = 'maven-plugin'
+ licenses {
+ license {
+ name = 'The Apache License, Version 2.0'
+ url = 'https://www.apache.org/licenses/LICENSE-2.0.txt'
+ }
+ }
+ developers {
+ developer {
+ id = 'apache-kafka'
+ name = 'Apache Kafka Team'
+ email = 'dev@kafka.apache.org'
+ }
+ }
+ scm {
+ connection = 'scm:git:https://github.com/apache/kafka.git'
+ developerConnection = 'scm:git:https://github.com/apache/kafka.git'
+ url = 'https://github.com/apache/kafka'
+ }
+ }
+ }
+ }
+}
diff --git a/api-checker/maven-plugin/src/main/java/org/apache/kafka/maven/KafkaInternalApiCheckerMojo.java b/api-checker/maven-plugin/src/main/java/org/apache/kafka/maven/KafkaInternalApiCheckerMojo.java
new file mode 100644
index 0000000000000..a08a8046d8195
--- /dev/null
+++ b/api-checker/maven-plugin/src/main/java/org/apache/kafka/maven/KafkaInternalApiCheckerMojo.java
@@ -0,0 +1,226 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 org.apache.kafka.maven;
+
+import org.apache.kafka.apicheck.PublicApiChecker;
+import org.apache.kafka.apicheck.PublicApiViolation;
+import org.apache.kafka.apicheck.CheckResult;
+import org.apache.kafka.apicheck.ViolationReporter;
+
+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.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 java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Maven plugin for checking that external projects don't use internal Kafka APIs.
+ *
+ * Scans compiled bytecode (.class files) under the project's build output directory, so it
+ * works uniformly for Java, Scala, Kotlin and any other JVM-language consumer. Runs during the
+ * verify phase after compilation has produced the bytecode it inspects.
+ */
+@Mojo(name = "verify",
+ defaultPhase = LifecyclePhase.VERIFY,
+ requiresDependencyResolution = ResolutionScope.COMPILE_PLUS_RUNTIME,
+ threadSafe = true)
+public class KafkaInternalApiCheckerMojo extends AbstractMojo {
+
+ /**
+ * The Maven project.
+ */
+ @Parameter(defaultValue = "${project}", required = true, readonly = true)
+ private MavenProject project;
+
+ /**
+ * Enable/disable the checker.
+ */
+ @Parameter(property = "kafka.internal-api-checker.enabled", defaultValue = "true")
+ private boolean enabled;
+
+ /**
+ * Fail build on violations.
+ */
+ @Parameter(property = "kafka.internal-api-checker.failOnViolation", defaultValue = "true")
+ private boolean failOnViolation;
+
+ /**
+ * Fail the build when no {@code org.apache.kafka:*} artifact is on the project's
+ * classpath. The default is {@code false} for back-compat (the mojo warns and skips),
+ * but on a project that's expected to depend on Kafka, turning this on catches
+ * classpath/config mistakes that would otherwise produce a meaningless "0 violations"
+ * report.
+ */
+ @Parameter(property = "kafka.internal-api-checker.failOnNoKafkaDependency", defaultValue = "false")
+ private boolean failOnNoKafkaDependency;
+
+ /**
+ * Compiled-class directories to scan. Defaults to the project's main and test build output
+ * directories. Each entry may be a directory of {@code .class} files, an individual
+ * {@code .class} file, or a {@code .jar} archive.
+ */
+ @Parameter
+ private List classesDirectories;
+
+
+ /**
+ * Report file location.
+ */
+ @Parameter(defaultValue = "${project.build.directory}/reports/kafka-internal-api-usage.txt")
+ private File reportFile;
+
+ @Override
+ public void execute() throws MojoExecutionException, MojoFailureException {
+ if (!enabled) {
+ getLog().info("KafkaInternalApiChecker is disabled, skipping...");
+ return;
+ }
+
+ if (classesDirectories == null || classesDirectories.isEmpty()) {
+ classesDirectories = getDefaultClassesDirectories();
+ }
+
+ getLog().info("Checking for internal Kafka API usage in compiled bytecode...");
+
+ List kafkaJars = getKafkaJarsFromDependencies();
+ if (kafkaJars.isEmpty()) {
+ handleNoKafkaDependency();
+ return;
+ }
+
+ List classRoots = PublicApiChecker.collectExistingRoots(classesDirectories);
+ if (classRoots.isEmpty()) {
+ getLog().info("No class files found, skipping internal API check");
+ return;
+ }
+
+ runCheck(kafkaJars, classRoots);
+ }
+
+ private void handleNoKafkaDependency() throws MojoFailureException {
+ String msg = "No org.apache.kafka:* dependencies found on the project classpath. "
+ + "The checker cannot derive an API surface and would produce a meaningless "
+ + "'0 violations' report — likely a classpath or configuration issue.";
+ if (failOnNoKafkaDependency) {
+ throw new MojoFailureException(msg);
+ }
+ getLog().warn(msg + " Skipping internal API check. "
+ + "Set true to make this fatal.");
+ }
+
+ private void runCheck(List kafkaJars, List classRoots)
+ throws MojoExecutionException, MojoFailureException {
+ try {
+ getLog().info("Scanning " + classRoots.size() + " class file root(s) for internal API usage");
+ CheckResult result = new PublicApiChecker(kafkaJars).checkBytecode(classRoots);
+ reportResults(result);
+ } catch (IOException e) {
+ throw new MojoExecutionException("Failed to check internal API usage: " + e.getMessage(), e);
+ }
+ }
+
+ private void reportResults(CheckResult result) throws MojoFailureException, IOException {
+ List violations = result.violations();
+ List suppressions = result.suppressions();
+
+ ViolationReporter reporter = new ViolationReporter();
+ reporter.writeTextReport(violations, suppressions, reportFile);
+ reporter.printToConsole(violations, suppressions, true);
+
+ getLog().info("Internal API usage check completed. Report written to: " + reportFile.getAbsolutePath());
+
+ long unjustified = suppressions.stream().filter(PublicApiViolation::lacksReason).count();
+ if (unjustified > 0) {
+ getLog().warn(unjustified + " suppression(s) carry no reason — KIP-1265 requires a justification on every @SuppressKafkaInternalApiUsage");
+ }
+
+ if (violations.isEmpty()) {
+ getLog().info("No internal API usage found.");
+ return;
+ }
+
+ String message = String.format("Found %d internal API usage violations. See report: %s",
+ violations.size(), reportFile.getAbsolutePath());
+ if (failOnViolation) {
+ throw new MojoFailureException(message);
+ }
+ getLog().warn(message);
+ }
+
+ /**
+ * Default to the project's main compiled output, matching the Gradle plugin's behaviour
+ * (which feeds {@code sourceSets.main.output.classesDirs}). Test code legitimately uses
+ * internal/test utilities, so including it by default would create noise that isn't a
+ * real consumer-side concern. Users who want to scan test code can opt in by setting
+ * {@code } explicitly.
+ */
+ private List getDefaultClassesDirectories() {
+ List dirs = new ArrayList<>();
+ File mainClasses = new File(project.getBuild().getOutputDirectory());
+ if (mainClasses.exists()) {
+ dirs.add(mainClasses);
+ }
+ return dirs;
+ }
+
+ private List getKafkaJarsFromDependencies() {
+ List kafkaJars = new ArrayList<>();
+
+ for (Artifact artifact : project.getArtifacts()) {
+ if ("org.apache.kafka".equals(artifact.getGroupId())) {
+ kafkaJars.add(artifact.getFile());
+ getLog().debug("Found Kafka dependency: " + artifact.getFile().getName());
+ }
+ }
+
+ return kafkaJars;
+ }
+
+ // Getters and setters for testing
+ public void setProject(MavenProject project) {
+ this.project = project;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled = enabled;
+ }
+
+ public void setFailOnViolation(boolean failOnViolation) {
+ this.failOnViolation = failOnViolation;
+ }
+
+ public void setFailOnNoKafkaDependency(boolean failOnNoKafkaDependency) {
+ this.failOnNoKafkaDependency = failOnNoKafkaDependency;
+ }
+
+ public void setClassesDirectories(List classesDirectories) {
+ this.classesDirectories = classesDirectories;
+ }
+
+ public void setReportFile(File reportFile) {
+ this.reportFile = reportFile;
+ }
+}
\ No newline at end of file
diff --git a/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml b/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml
new file mode 100644
index 0000000000000..1850e42eef915
--- /dev/null
+++ b/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml
@@ -0,0 +1,134 @@
+
+
+
+
+ Apache Kafka Internal API Checker Maven Plugin
+ Maven plugin to check that external projects don't use internal Kafka APIs
+ org.apache.kafka
+ kafka-internal-api-checker-maven-plugin
+ @version@
+ kafka-internal-api-checker
+ false
+ true
+
+
+
+ verify
+ Checks source code for usage of internal Kafka APIs
+ false
+ true
+ false
+ false
+ false
+ true
+ verify
+ org.apache.kafka.maven.KafkaInternalApiCheckerMojo
+ java
+ per-lookup
+ once-per-session
+ true
+ compile+runtime
+
+
+
+ project
+ org.apache.maven.project.MavenProject
+ true
+ false
+ The Maven project
+
+
+
+ enabled
+ boolean
+ false
+ true
+ Enable/disable the internal API checker
+
+
+
+ failOnViolation
+ boolean
+ false
+ true
+ Fail build on violations
+
+
+
+ failOnNoKafkaDependency
+ boolean
+ false
+ true
+ Fail the build when no org.apache.kafka:* dependency is found on the classpath (default: false, which warns and skips)
+
+
+
+ classesDirectories
+ java.util.List
+ false
+ true
+ Compiled-class directories or jars to scan for internal API usage
+
+
+
+ reportFile
+ java.io.File
+ false
+ true
+ Report file location
+
+
+
+
+
+ ${kafka.internal-api-checker.enabled}
+ ${kafka.internal-api-checker.failOnViolation}
+ ${kafka.internal-api-checker.failOnNoKafkaDependency}
+
+
+
+
+
+
+
+
+ org.apache.maven
+ maven-plugin-api
+ jar
+ @mavenApiVersion@
+
+
+ org.apache.maven
+ maven-core
+ jar
+ @mavenApiVersion@
+
+
+ org.apache.maven
+ maven-artifact
+ jar
+ @mavenApiVersion@
+
+
+ org.apache.maven.plugin-tools
+ maven-plugin-annotations
+ jar
+ @mavenPluginAnnotationsVersion@
+
+
+
\ No newline at end of file
diff --git a/api-checker/settings.gradle b/api-checker/settings.gradle
new file mode 100644
index 0000000000000..8389e1cf9d6ed
--- /dev/null
+++ b/api-checker/settings.gradle
@@ -0,0 +1,20 @@
+// Licensed to the Apache Software Foundation (ASF) under one or more
+// contributor license agreements. See the NOTICE file distributed with
+// this work for additional information regarding copyright ownership.
+// The ASF licenses this file to You 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.
+
+rootProject.name = 'kafka-api-checker'
+
+include 'core'
+include 'gradle-plugins'
+include 'maven-plugin'
diff --git a/build.gradle b/build.gradle
index 5b8c8e8c874b6..c4ef7d8544c8e 100644
--- a/build.gradle
+++ b/build.gradle
@@ -42,6 +42,11 @@ plugins {
id 'org.scoverage' version '8.1' apply false
id 'com.gradleup.shadow' version '9.4.1' apply false
id 'com.diffplug.spotless' version "8.4.0"
+
+ // Pre-register the KIP-1265 checker so `apply plugin:` resolves it inside subprojects { }.
+ // The plugin itself ships from the `:api-checker` included build (see settings.gradle's
+ // pluginManagement block).
+ id 'org.apache.kafka.public-api-checker' apply false
}
ext {
@@ -335,6 +340,8 @@ subprojects {
apply plugin: 'checkstyle'
apply plugin: "com.github.spotbugs"
+ apply plugin: 'org.apache.kafka.public-api-checker'
+
// We use the shadow plugin for the jmh-benchmarks module and the `-all` jar can get pretty large, so
// don't publish it
@@ -734,6 +741,31 @@ subprojects {
check.dependsOn('javadoc')
+kafkaPublicApiChecker {
+
+ // Jars produced by this project — the surface that's actually validated.
+ projectJarFiles.from(jar.archiveFile)
+
+ // Sibling Kafka modules this project depends on (direct or transitive). Their classes
+ // are merged into the scanned surface so cross-module @InterfaceAudience.Public references
+ // resolve, but they don't contribute to this module's MISSING_JAVADOC or cascade
+ // iteration — each module checks its own surface.
+ referenceJarFiles.from(
+ configurations.compileClasspath.incoming.artifactView {
+ // Resolve project deps to their assembled JAR (not the classes-dir variant Gradle
+ // picks by default for compile-classpath), so the scanner only sees real jars.
+ attributes {
+ attribute(org.gradle.api.attributes.LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE,
+ objects.named(org.gradle.api.attributes.LibraryElements,
+ org.gradle.api.attributes.LibraryElements.JAR))
+ }
+ componentFilter { it instanceof org.gradle.api.artifacts.component.ProjectComponentIdentifier }
+ }.files
+ )
+
+ enabled = true
+ failOnViolation = true
+}
task systemTestLibs(dependsOn: jar)
if (!sourceSets.test.allSource.isEmpty()) {
@@ -2136,6 +2168,7 @@ project(':clients') {
include "**/org/apache/kafka/common/annotation/*"
include "**/org/apache/kafka/common/config/*"
include "**/org/apache/kafka/common/config/provider/*"
+ include "**/org/apache/kafka/common/config/types/*"
include "**/org/apache/kafka/common/errors/*"
include "**/org/apache/kafka/common/header/*"
include "**/org/apache/kafka/common/metrics/*"
diff --git a/checkstyle/import-control-api-checker.xml b/checkstyle/import-control-api-checker.xml
new file mode 100644
index 0000000000000..fc956fc428330
--- /dev/null
+++ b/checkstyle/import-control-api-checker.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java
index 26674e1ccc105..577eb8da977e6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionOptions.java
@@ -14,8 +14,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
+@InterfaceAudience.Public
public class AbortTransactionOptions extends AbstractOptions {
@Override
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java
index 602c4f96443ca..e1501fc1bd61c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionResult.java
@@ -14,16 +14,19 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Map;
/**
* The result of {@link Admin#abortTransaction(AbortTransactionSpec, AbortTransactionOptions)}.
*/
+@InterfaceAudience.Public
public class AbortTransactionResult {
private final Map> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java
index 6af5597872638..ed55dd1709df0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbortTransactionSpec.java
@@ -14,12 +14,15 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Objects;
+@InterfaceAudience.Public
public class AbortTransactionSpec {
private final TopicPartition topicPartition;
private final long producerId;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java
index 12effaf4e6372..56959619ac162 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AbstractOptions.java
@@ -17,11 +17,13 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
/*
* This class implements the common APIs that are shared by Options classes for various AdminClient commands
*/
@SuppressWarnings("rawtypes")
+@InterfaceAudience.Public
public abstract class AbstractOptions {
protected Integer timeoutMs = null;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java
index 81e889db30d61..09013c36876a9 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterOptions.java
@@ -16,6 +16,7 @@
*/
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.common.protocol.Errors;
@@ -33,6 +34,7 @@
* If not provided, the cluster id check is skipped.
*/
@InterfaceStability.Stable
+@InterfaceAudience.Public
public class AddRaftVoterOptions extends AbstractOptions {
private Optional clusterId = Optional.empty();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java
index d42204c5e4e79..04fe2de20eef5 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AddRaftVoterResult.java
@@ -17,6 +17,7 @@
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.annotation.InterfaceStability;
/**
@@ -25,6 +26,7 @@
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Stable
+@InterfaceAudience.Public
public class AddRaftVoterResult {
private final KafkaFuture result;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
index 313c29ca49ee1..942b71d64336f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/Admin.java
@@ -29,6 +29,7 @@
import org.apache.kafka.common.Uuid;
import org.apache.kafka.common.acl.AclBinding;
import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.common.config.ConfigResource;
import org.apache.kafka.common.errors.FeatureUpdateFailedException;
@@ -122,6 +123,7 @@
* version required.
*
*/
+@InterfaceAudience.Public
public interface Admin extends AutoCloseable {
/**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java
index 204579605bc13..0c2923bab4c67 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClient.java
@@ -17,6 +17,8 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Map;
import java.util.Properties;
@@ -27,6 +29,7 @@
*
* This class may be removed in a later release, but has not been marked as deprecated to avoid unnecessary noise.
*/
+@InterfaceAudience.Public
public abstract class AdminClient implements Admin {
/**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java
index 3908688615657..62e72bb6ba004 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AdminClientConfig.java
@@ -20,6 +20,7 @@
import org.apache.kafka.clients.ClientDnsLookup;
import org.apache.kafka.clients.CommonClientConfigs;
import org.apache.kafka.clients.MetadataRecoveryStrategy;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.config.AbstractConfig;
import org.apache.kafka.common.config.ConfigDef;
import org.apache.kafka.common.config.ConfigDef.Importance;
@@ -41,6 +42,7 @@
/**
* The AdminClient configuration class, which also contains constants for configuration entry names.
*/
+@InterfaceAudience.Public
public class AdminClientConfig extends AbstractConfig {
private static final ConfigDef CONFIG;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java
index 027ccd1e9e028..39dac2ac6b628 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasOptions.java
@@ -17,11 +17,14 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Collection;
/**
* Options for {@link Admin#alterClientQuotas(Collection, AlterClientQuotasOptions)}.
*/
+@InterfaceAudience.Public
public class AlterClientQuotasOptions extends AbstractOptions {
private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java
index 4906184b3c925..2de0158e42dcc 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterClientQuotasResult.java
@@ -18,6 +18,7 @@
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.quota.ClientQuotaEntity;
import java.util.Collection;
@@ -26,6 +27,7 @@
/**
* The result of the {@link Admin#alterClientQuotas(Collection, AlterClientQuotasOptions)} call.
*/
+@InterfaceAudience.Public
public class AlterClientQuotasResult {
private final Map> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java
index 789c9f64a93aa..f3afc4dbbb191 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigOp.java
@@ -17,6 +17,8 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
@@ -41,6 +43,7 @@
* new AlterConfigOp(new ConfigEntry(loggerName, "DEBUG"), OpType.SET)
*
*/
+@InterfaceAudience.Public
public class AlterConfigOp {
public enum OpType {
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java
index 31812bbfc1611..689a787c97091 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsOptions.java
@@ -17,11 +17,14 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Map;
/**
* Options for {@link Admin#incrementalAlterConfigs(Map)}.
*/
+@InterfaceAudience.Public
public class AlterConfigsOptions extends AbstractOptions {
private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java
index cd9279300de84..51639d3b23d36 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConfigsResult.java
@@ -18,6 +18,7 @@
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.config.ConfigResource;
import java.util.Map;
@@ -25,6 +26,7 @@
/**
* The result of the {@link Admin#incrementalAlterConfigs(Map, AlterConfigsOptions)} call.
*/
+@InterfaceAudience.Public
public class AlterConfigsResult {
private final Map> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java
index c2428b6430a9d..ea3f80bb4a391 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsOptions.java
@@ -14,12 +14,16 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Map;
/**
* Options for the {@link AdminClient#alterConsumerGroupOffsets(String, Map, AlterConsumerGroupOffsetsOptions)} call.
*/
+@InterfaceAudience.Public
public class AlterConsumerGroupOffsetsOptions extends AbstractOptions {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java
index 8d78a16b57458..b20a55c8b1e25 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterConsumerGroupOffsetsResult.java
@@ -18,6 +18,7 @@
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.kafka.common.protocol.Errors;
@@ -28,6 +29,7 @@
/**
* The result of the {@link AdminClient#alterConsumerGroupOffsets(String, Map)} call.
*/
+@InterfaceAudience.Public
public class AlterConsumerGroupOffsetsResult {
private final KafkaFuture