You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@kafka.apache.org by ma...@apache.org on 2026/07/02 08:54:35 UTC
(kafka) branch trunk updated: KAFKA-20032: KIP-1265 Automatic detection of internal API usage (#21337)
This is an automated email from the ASF dual-hosted git repository.
omkreddy pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git
The following commit(s) were added to refs/heads/trunk by this push:
new fac6838045e KAFKA-20032: KIP-1265 Automatic detection of internal API usage (#21337)
fac6838045e is described below
commit fac6838045e8de367666730d8f12b434596eb71c
Author: ashwinpankaj <ap...@confluent.io>
AuthorDate: Thu Jul 2 14:24:27 2026 +0530
KAFKA-20032: KIP-1265 Automatic detection of internal API usage (#21337)
## Summary
Wires up the KIP-1265 mechanism for explicit declaration and automated
enforcement of Apache Kafka's public API surface. Adds two annotations
(`@InterfaceAudience.Public` / `.Private`, modelled on Hadoop and
orthogonal to the existing `@InterfaceStability`), an auditable escape
hatch (`@SuppressKafkaInternalApiUsage`), a Gradle checker that
cross-validates the javadoc HTML against the bytecode annotations and
cascades through public method signatures (including class-header
extends/implements + generic supertype arguments), and a Gradle/Maven
checker plugin for external plugin developers. Every javadoc'd class
across the 10 docsJar modules is annotated, and `./gradlew docsJar` now
finalizes with the checker on every project — 0 violations, 53
suppressions, every suppression carrying a reason the next reviewer can
audit.
JIRA: [KAFKA-20032](https://issues.apache.org/jira/browse/KAFKA-20032) ·
KIP:
[KIP-1265](https://cwiki.apache.org/confluence/display/KAFKA/KIP-1265%3A+Mechanism+for+automatic+detection+of+internal+API+usage)
## What's in this PR
### Annotations (in `o.a.k.common.annotation`)
- `@InterfaceAudience.Public` / `.Private` — marker annotations, RUNTIME
retention, `@Documented`, `@Target(TYPE)`. The audience dimension is
orthogonal to `@InterfaceStability`; a class may carry both.
- `@SuppressKafkaInternalApiUsage("reason")` —
class/method/field/constructor target, RUNTIME retention. Required
reason text is surfaced in every checker report so each escape hatch is
reviewable inline.
### Audience inheritance
A class's effective audience is its own direct `@InterfaceAudience`
annotation if present, otherwise the audience of its nearest annotated
enclosing class. Default everywhere is `@Private` per the KIP. A nested
class can override an inherited `@Public` with explicit
`@InterfaceAudience.Private`. This matches Hadoop's model and aligns
with how javadoc treats nested classes — they're documented under the
outer's page, so requiring a separate annotation on every public nested
class would be redundant noise. Inheritance also handles protected and
package-private nested classes correctly: they appear in javadoc only as
part of the outer's docs, so the checker only enforces direct `@Public`
for the `MISSING_JAVADOC` check, while cascade reference checks accept
inherited `@Public` for any nested type. Net effect: ~640 javadoc'd
classes are covered by ~550 top-level annotations, with the rest carried
by inheritance.
### Producer-side checker (`kafkaPublicApiChecker` — runs as part of
`docsJar`)
Cross-validates each module's javadoc jar against its bytecode-level
annotations. Reports four categories of finding:
- `MISSING_PUBLICAPI_ANNOTATION` — a class is in the javadoc HTML but
isn't effectively `@Public`.
- `MISSING_JAVADOC` — a class carries a direct `@Public` but no HTML
page in the javadoc jar.
- Method/field cascade (`INVALID_RETURN_TYPE` / `INVALID_PARAMETER_TYPE`
/ `INVALID_EXCEPTION_TYPE` / `INVALID_FIELD_TYPE`) — a public member
signature on a `@Public` class leaks an internal type. Position-aware:
the same generic walker that catches `Map<String, InternalFoo>` in a
return type emits `INVALID_RETURN_TYPE`, a parameter-position leak
emits `INVALID_PARAMETER_TYPE`, and a throws-clause leak emits
`INVALID_EXCEPTION_TYPE`. Findings are deduped by
(violation-type, owner, member, referenced-type) so the descriptor
walk and the generic-signature walk don't double-report the same leak.
- Supertype cascade (`INVALID_SUPERTYPE`) — a `@Public` class extends or
implements an internal type the consumer can name (skipped for
package-private supertypes that a consumer can't even reference). Also
covers generic supertype arguments via the JVM signature attribute, so
`extends Windows<InternalWindow>` is caught.
Cascade findings whose containing member or class carries
`@SuppressKafkaInternalApiUsage` divert to a separate suppressions
section with the supplied reason; the report lists any suppression
missing a reason as a warning.
### Consumer-side checker (`kafkaInternalApiChecker` plugin)
Gradle and Maven plugins
(`o.a.k:kafka-internal-api-checker-gradle-plugin`,
`o.a.k:kafka-internal-api-checker-maven-plugin`) for external plugin
developers — walk compiled bytecode (Java/Scala/Kotlin, uniformly) and
flag references to any `org.apache.kafka.*` type that isn't `@Public`.
Violations are grouped in the report by the consumer class (the file a
developer needs to open), with the leaked Kafka type carried in the
description. The same `@SuppressKafkaInternalApiUsage` annotation
applies to known/intentional consumer-side exposures. Skip the check
for one invocation via `-PkafkaInternalApiChecker.skip` (Gradle) or
`-Dkafka.internal-api-checker.enabled=false` (Maven). A symmetric
`-PkafkaPublicApiChecker.skip` exists for the producer-side checker.
### Cross-module support
Per-module checks include sibling Kafka jars as `referenceJarFiles` —
reference classes contribute to the surface so cross-module `@Public`
resolution works (e.g. `:storage:storage-api` referencing
`o.a.k.common.TopicIdPartition` from `:clients`), but they don't
participate in the module's own `MISSING_JAVADOC` or cascade iteration.
Gradle wires this via
`configurations.compileClasspath.incoming.artifactView { componentFilter
{ it instanceof ProjectComponentIdentifier } … }` filtered to the JAR
artifact variant.
## Coverage
`./gradlew docsJar` triggers the checker on every subproject. Per-module
results across the 10 docsJar modules:
| Module | Violations | Suppressions | |---|---:|---:| | `:clients` | 0
| 18 | | `:streams` | 0 | 27 | | `:streams:test-utils` | 0 | 2 | |
`:streams:streams-scala` | 0 | 0 | | `:connect:api` | 0 | 0 | |
`:connect:mirror-client` | 0 | 6 | | `:connect:test-plugins` | 0 | 0 | |
`:storage:storage-api` | 0 | 0 | | `:tools:tools-api` | 0 | 0 | |
`:group-coordinator:group-coordinator-api` | 0 | 0 | | **Total** | **0**
| **53** |
## Suppressions in this PR
Every suppression carries a `KIP-1265: …` reason in the source and is
rendered in the checker report. They mark cascade leaks that can't be
cleanly resolved without API-shape changes the KIP scope doesn't cover.
Grouped by pattern:
- **Internal `Time`/`Metrics` injection on public ctors** — `Metrics`,
`KafkaMetric`, `KafkaStreams`, the JWT retrievers/validator, and the
mirror-client constructors take internal `o.a.k.common.utils.Time` (or
`Metrics`) for test injection.
- **Internal partition-assignor supertypes** — `StickyAssignor`,
`CooperativeStickyAssignor`, `RangeAssignor`, `RoundRobinAssignor`
extend internal `AbstractStickyAssignor` / `AbstractPartitionAssignor`.
Reachable only through the existing `extends`; the methods themselves
are stable.
- **OAuthBearer plugin supertypes** — `JwtRetriever`, `JwtValidator`
implement internal `OAuthBearerConfigurable`.
- **Streams windowing generic supertypes** — `TimeWindows`,
`UnlimitedWindows` extend `Windows<TimeWindow>` /
`Windows<UnlimitedWindow>`; `TimeWindowedSerializer`,
`SessionWindowedSerializer` implement internal `WindowedSerializer`.
- **`QueryableStoreType` / `QueryableStoreTypes`** —
`create(StateStoreProvider)` and its five nested overrides take an
internal type.
- **`:streams:test-utils`** — `MockProcessorContext.recordCollector()`
returns internal `RecordCollector`; the class also implements
`RecordCollector.Supplier`.
These are deliberate, reviewable, and easy to grep. Follow-up KIPs can
decide per-case whether to promote the internal type or refactor the
member signature.
## Genuine public-API leaks surfaced and fixed
The checker found cases where the *de-facto* public surface didn't match
the published javadoc:
1. **`o.a.k.common.config.types.Password`** — already `public class`,
returned by `AbstractConfig.getPassword()` which is `@Public`, but the
`…common.config.types.*` package was missing from the `:clients` javadoc
include list. Brought it into the include list and annotated `Password`
with `@Public`.
2. **`SampledStat.Sample`** — `protected static class` returned by two
public methods (`current(long)`, `oldest(long)`) of `@Public abstract
SampledStat`. Subclasses extending `SampledStat` have always been able
to touch this type; the annotation now makes the de-facto contract
explicit. Visibility unchanged.
## Implementation notes
The checker lives in its own composite-included Gradle build at
`api-checker/` so its Maven runtime dependencies stay off Kafka's main
build classpath and each published artifact carries only the classes its
consumers need. Three subprojects:
- **`api-checker/core`** — ASM-based scanner + validators + reporter
(no Gradle / Maven dependencies). `PublicApiChecker` is the facade.
`ApiSurfaceScanner` does a two-pass static `scan(projectJars,
referenceJars) → ApiSurface`. `CascadeValidator` runs the
extends/implements + member-signature cascade.
`JavadocConsistencyValidator` cross-checks the javadoc HTML.
`PluginDeveloperApiUsageScanner` is the consumer-side walker.
- **`api-checker/gradle-plugins`** — published as
`kafka-internal-api-checker-gradle-plugin`. Also hosts the internal
`org.apache.kafka.public-api-checker` plugin Kafka's own build applies
(its marker publication is disabled — Kafka-internal only).
- **`api-checker/maven-plugin`** — published as
`kafka-internal-api-checker-maven-plugin`. A `PluginXmlParityTest`
locks the generated `plugin.xml` Mojo descriptor against the
configuration fields on the Mojo class, so adding a new parameter
without exposing it (or vice versa) fails CI.
**No classloading anywhere.** All bytecode reading goes through ASM, so
a class with broken transitive deps on the checker's classpath (gRPC
stubs, telemetry shims, etc.) can't trip `LinkageError` /
`NoClassDefFoundError`.
**Reproducible reports.** Reports omit wall-clock timestamps and sort
by class then member, so two runs over the same inputs produce
byte-identical files (clean diffs in CI, clean review comparisons).
**Documented scanner gaps.** The consumer-side bytecode scanner walks
every callable instruction, field type, declared exception, and generic
signature, but four reference kinds are intentionally not followed
(documented at `docs/apis/internal-api-checker.md`): parameter
annotations, JSR 308 type-use annotations, class literals appearing
*only* inside annotation element values, and Java's compile-time
inlining of `public static final` primitive/`String` constants. In
practice these don't show up in plugin/connector code, but a "0
violations" report does not strictly exclude them.
## Test plan
- [x] `./gradlew docsJar` — every module's `kafkaPublicApiChecker` runs
and reports 0 violations.
- [x] Per-module reports inspected: 0 violations, 53 auditable
suppressions across `:clients` (18), `:streams` (27),
`:streams:test-utils` (2), and `:connect:mirror-client` (6).
- [x] `./gradlew :api-checker:core:check
:api-checker:gradle-plugins:check
:api-checker:maven-plugin:check` — checkstyle + unit tests clean.
- [x] End-to-end Gradle and Maven consumer tests (sample plugin app
referencing `KafkaProducer`, `ProducerRecord`, `Callback`,
`RecordMetadata`, `StringSerializer`) produce the expected violations
and suppressions. A deliberate-failure variant referencing
`org.apache.kafka.clients.producer.internals.ProducerInterceptors` is
flagged as `INTERNAL_API_USAGE` by both plugins and grouped in the
report under the consumer class, not the leaked Kafka type.
- [x] `-PkafkaPublicApiChecker.skip` / `-PkafkaInternalApiChecker.skip`
gates verified to short-circuit at configuration time.
- [x] Existing Kafka tests continue to pass.
Reviewers: Manikumar Reddy <ma...@gmail.com>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <no...@anthropic.com>
---
api-checker/README.md | 90 +++
api-checker/build.gradle | 146 +++++
api-checker/core/build.gradle | 53 ++
.../java/org/apache/kafka/apicheck/ApiSurface.java | 169 ++++++
.../apache/kafka/apicheck/ApiSurfaceScanner.java | 225 ++++++++
.../apache/kafka/apicheck/CascadeValidator.java | 455 +++++++++++++++
.../org/apache/kafka/apicheck/CheckResult.java | 52 ++
.../java/org/apache/kafka/apicheck/ClassFacts.java | 162 ++++++
.../apicheck/JavadocConsistencyValidator.java | 118 ++++
.../apicheck/PluginDeveloperApiUsageScanner.java | 624 +++++++++++++++++++++
.../apache/kafka/apicheck/PublicApiChecker.java | 121 ++++
.../apache/kafka/apicheck/PublicApiViolation.java | 109 ++++
.../kafka/apicheck/ReasonCaptureVisitor.java | 54 ++
.../apache/kafka/apicheck/ViolationReporter.java | 178 ++++++
.../kafka/apicheck/ApiSurfaceScannerTest.java | 215 +++++++
.../org/apache/kafka/apicheck/ApiSurfaceTest.java | 200 +++++++
.../kafka/apicheck/CascadeValidatorTest.java | 424 ++++++++++++++
.../apicheck/JavadocConsistencyValidatorTest.java | 206 +++++++
.../PluginDeveloperApiUsageScannerTest.java | 513 +++++++++++++++++
.../kafka/apicheck/PublicApiCheckerTest.java | 220 ++++++++
.../org/apache/kafka/apicheck/AsmClassFactory.java | 366 ++++++++++++
.../org/apache/kafka/apicheck/TempJarBuilder.java | 89 +++
api-checker/gradle-plugins/build.gradle | 79 +++
.../gradle/KafkaInternalApiCheckerExtension.java | 118 ++++
.../gradle/KafkaInternalApiCheckerPlugin.java | 127 +++++
.../kafka/gradle/KafkaInternalApiCheckerTask.java | 190 +++++++
.../gradle/KafkaPublicApiCheckerExtension.java | 98 ++++
.../kafka/gradle/KafkaPublicApiCheckerPlugin.java | 117 ++++
.../kafka/gradle/KafkaPublicApiCheckerTask.java | 180 ++++++
.../gradle/KafkaInternalApiCheckerPluginTest.java | 93 +++
.../gradle/KafkaInternalApiCheckerTaskTest.java | 169 ++++++
.../gradle/KafkaPublicApiCheckerTaskTest.java | 188 +++++++
api-checker/maven-plugin/build.gradle | 65 +++
.../kafka/maven/KafkaInternalApiCheckerMojo.java | 226 ++++++++
.../src/main/resources/META-INF/maven/plugin.xml | 134 +++++
.../apache/kafka/maven/PluginXmlParityTest.java | 146 +++++
api-checker/settings.gradle | 20 +
build.gradle | 33 ++
checkstyle/import-control-api-checker-test.xml | 35 ++
checkstyle/import-control-api-checker.xml | 32 ++
.../clients/admin/AbortTransactionOptions.java | 4 +
.../clients/admin/AbortTransactionResult.java | 3 +
.../kafka/clients/admin/AbortTransactionSpec.java | 3 +
.../kafka/clients/admin/AbstractOptions.java | 2 +
.../kafka/clients/admin/AddRaftVoterOptions.java | 2 +
.../kafka/clients/admin/AddRaftVoterResult.java | 2 +
.../java/org/apache/kafka/clients/admin/Admin.java | 2 +
.../apache/kafka/clients/admin/AdminClient.java | 3 +
.../kafka/clients/admin/AdminClientConfig.java | 2 +
.../clients/admin/AlterClientQuotasOptions.java | 3 +
.../clients/admin/AlterClientQuotasResult.java | 2 +
.../apache/kafka/clients/admin/AlterConfigOp.java | 3 +
.../kafka/clients/admin/AlterConfigsOptions.java | 3 +
.../kafka/clients/admin/AlterConfigsResult.java | 2 +
.../admin/AlterConsumerGroupOffsetsOptions.java | 4 +
.../admin/AlterConsumerGroupOffsetsResult.java | 2 +
.../admin/AlterPartitionReassignmentsOptions.java | 3 +
.../admin/AlterPartitionReassignmentsResult.java | 2 +
.../clients/admin/AlterReplicaLogDirsOptions.java | 3 +
.../clients/admin/AlterReplicaLogDirsResult.java | 2 +
.../admin/AlterShareGroupOffsetsOptions.java | 4 +
.../admin/AlterShareGroupOffsetsResult.java | 2 +
.../admin/AlterStreamsGroupOffsetsOptions.java | 4 +-
.../admin/AlterStreamsGroupOffsetsResult.java | 2 +
.../admin/AlterUserScramCredentialsOptions.java | 3 +
.../admin/AlterUserScramCredentialsResult.java | 2 +
.../clients/admin/ClassicGroupDescription.java | 2 +
.../org/apache/kafka/clients/admin/Config.java | 3 +
.../apache/kafka/clients/admin/ConfigEntry.java | 3 +
.../clients/admin/ConsumerGroupDescription.java | 2 +
.../kafka/clients/admin/CreateAclsOptions.java | 3 +
.../kafka/clients/admin/CreateAclsResult.java | 2 +
.../admin/CreateDelegationTokenOptions.java | 2 +
.../clients/admin/CreateDelegationTokenResult.java | 2 +
.../clients/admin/CreatePartitionsOptions.java | 3 +
.../clients/admin/CreatePartitionsResult.java | 2 +
.../kafka/clients/admin/CreateTopicsOptions.java | 3 +
.../kafka/clients/admin/CreateTopicsResult.java | 2 +
.../kafka/clients/admin/DeleteAclsOptions.java | 3 +
.../kafka/clients/admin/DeleteAclsResult.java | 2 +
.../admin/DeleteConsumerGroupOffsetsOptions.java | 4 +
.../admin/DeleteConsumerGroupOffsetsResult.java | 2 +
.../clients/admin/DeleteConsumerGroupsOptions.java | 4 +
.../clients/admin/DeleteConsumerGroupsResult.java | 3 +
.../kafka/clients/admin/DeleteRecordsOptions.java | 3 +
.../kafka/clients/admin/DeleteRecordsResult.java | 2 +
.../admin/DeleteShareGroupOffsetsOptions.java | 4 +
.../admin/DeleteShareGroupOffsetsResult.java | 2 +
.../clients/admin/DeleteShareGroupsOptions.java | 4 +
.../clients/admin/DeleteShareGroupsResult.java | 3 +
.../admin/DeleteStreamsGroupOffsetsOptions.java | 2 +
.../admin/DeleteStreamsGroupOffsetsResult.java | 2 +
.../clients/admin/DeleteStreamsGroupsOptions.java | 2 +
.../clients/admin/DeleteStreamsGroupsResult.java | 2 +
.../kafka/clients/admin/DeleteTopicsOptions.java | 3 +
.../kafka/clients/admin/DeleteTopicsResult.java | 2 +
.../apache/kafka/clients/admin/DeletedRecords.java | 3 +
.../kafka/clients/admin/DescribeAclsOptions.java | 2 +
.../kafka/clients/admin/DescribeAclsResult.java | 2 +
.../admin/DescribeClassicGroupsOptions.java | 3 +
.../clients/admin/DescribeClassicGroupsResult.java | 2 +
.../clients/admin/DescribeClientQuotasOptions.java | 2 +
.../clients/admin/DescribeClientQuotasResult.java | 2 +
.../clients/admin/DescribeClusterOptions.java | 3 +
.../kafka/clients/admin/DescribeClusterResult.java | 2 +
.../clients/admin/DescribeConfigsOptions.java | 3 +
.../kafka/clients/admin/DescribeConfigsResult.java | 2 +
.../admin/DescribeConsumerGroupsOptions.java | 3 +
.../admin/DescribeConsumerGroupsResult.java | 2 +
.../admin/DescribeDelegationTokenOptions.java | 2 +
.../admin/DescribeDelegationTokenResult.java | 2 +
.../clients/admin/DescribeFeaturesOptions.java | 4 +
.../clients/admin/DescribeFeaturesResult.java | 3 +
.../clients/admin/DescribeLogDirsOptions.java | 3 +
.../kafka/clients/admin/DescribeLogDirsResult.java | 2 +
.../admin/DescribeMetadataQuorumOptions.java | 4 +
.../admin/DescribeMetadataQuorumResult.java | 3 +
.../clients/admin/DescribeProducersOptions.java | 4 +
.../clients/admin/DescribeProducersResult.java | 3 +
.../admin/DescribeReplicaLogDirsOptions.java | 3 +
.../admin/DescribeReplicaLogDirsResult.java | 2 +
.../clients/admin/DescribeShareGroupsOptions.java | 4 +
.../clients/admin/DescribeShareGroupsResult.java | 3 +
.../admin/DescribeStreamsGroupsOptions.java | 2 +
.../clients/admin/DescribeStreamsGroupsResult.java | 2 +
.../kafka/clients/admin/DescribeTopicsOptions.java | 3 +
.../kafka/clients/admin/DescribeTopicsResult.java | 2 +
.../clients/admin/DescribeTransactionsOptions.java | 4 +
.../clients/admin/DescribeTransactionsResult.java | 3 +
.../admin/DescribeUserScramCredentialsOptions.java | 3 +
.../admin/DescribeUserScramCredentialsResult.java | 2 +
.../kafka/clients/admin/ElectLeadersOptions.java | 2 +
.../kafka/clients/admin/ElectLeadersResult.java | 3 +-
.../apache/kafka/clients/admin/EndpointType.java | 3 +
.../admin/ExpireDelegationTokenOptions.java | 3 +
.../clients/admin/ExpireDelegationTokenResult.java | 2 +
.../kafka/clients/admin/FeatureMetadata.java | 3 +
.../apache/kafka/clients/admin/FeatureUpdate.java | 4 +
.../kafka/clients/admin/FenceProducersOptions.java | 3 +
.../kafka/clients/admin/FenceProducersResult.java | 2 +
.../kafka/clients/admin/FinalizedVersionRange.java | 4 +
.../kafka/clients/admin/ForwardingAdmin.java | 2 +
.../apache/kafka/clients/admin/GroupListing.java | 3 +
.../kafka/clients/admin/KafkaAdminClient.java | 2 +
.../clients/admin/ListConfigResourcesOptions.java | 4 +
.../clients/admin/ListConfigResourcesResult.java | 2 +
.../admin/ListConsumerGroupOffsetsOptions.java | 3 +
.../admin/ListConsumerGroupOffsetsResult.java | 2 +
.../admin/ListConsumerGroupOffsetsSpec.java | 2 +
.../kafka/clients/admin/ListGroupsOptions.java | 3 +
.../kafka/clients/admin/ListGroupsResult.java | 2 +
.../kafka/clients/admin/ListOffsetsOptions.java | 3 +
.../kafka/clients/admin/ListOffsetsResult.java | 3 +
.../admin/ListPartitionReassignmentsOptions.java | 3 +
.../admin/ListPartitionReassignmentsResult.java | 2 +
.../admin/ListShareGroupOffsetsOptions.java | 4 +
.../clients/admin/ListShareGroupOffsetsResult.java | 3 +
.../clients/admin/ListShareGroupOffsetsSpec.java | 3 +
.../admin/ListStreamsGroupOffsetsOptions.java | 3 +-
.../admin/ListStreamsGroupOffsetsResult.java | 2 +
.../clients/admin/ListStreamsGroupOffsetsSpec.java | 2 +
.../kafka/clients/admin/ListTopicsOptions.java | 3 +
.../kafka/clients/admin/ListTopicsResult.java | 2 +
.../clients/admin/ListTransactionsOptions.java | 3 +
.../clients/admin/ListTransactionsResult.java | 2 +
.../kafka/clients/admin/LogDirDescription.java | 2 +
.../kafka/clients/admin/MemberAssignment.java | 3 +
.../kafka/clients/admin/MemberDescription.java | 3 +
.../apache/kafka/clients/admin/MemberToRemove.java | 2 +
.../clients/admin/NewPartitionReassignment.java | 3 +
.../apache/kafka/clients/admin/NewPartitions.java | 3 +
.../org/apache/kafka/clients/admin/NewTopic.java | 2 +
.../org/apache/kafka/clients/admin/OffsetSpec.java | 4 +
.../kafka/clients/admin/PartitionReassignment.java | 3 +
.../apache/kafka/clients/admin/ProducerState.java | 4 +
.../org/apache/kafka/clients/admin/QuorumInfo.java | 3 +
.../kafka/clients/admin/RaftVoterEndpoint.java | 2 +
.../kafka/clients/admin/RecordsToDelete.java | 3 +
.../RemoveMembersFromConsumerGroupOptions.java | 4 +
.../RemoveMembersFromConsumerGroupResult.java | 2 +
.../clients/admin/RemoveRaftVoterOptions.java | 2 +
.../kafka/clients/admin/RemoveRaftVoterResult.java | 2 +
.../clients/admin/RenewDelegationTokenOptions.java | 3 +
.../clients/admin/RenewDelegationTokenResult.java | 2 +
.../apache/kafka/clients/admin/ReplicaInfo.java | 4 +
.../kafka/clients/admin/ScramCredentialInfo.java | 3 +
.../apache/kafka/clients/admin/ScramMechanism.java | 3 +
.../kafka/clients/admin/ShareGroupDescription.java | 2 +
.../kafka/clients/admin/ShareMemberAssignment.java | 3 +
.../clients/admin/ShareMemberDescription.java | 4 +
.../clients/admin/SharePartitionOffsetInfo.java | 3 +
.../clients/admin/StreamsGroupDescription.java | 2 +
.../admin/StreamsGroupMemberAssignment.java | 2 +
.../admin/StreamsGroupMemberDescription.java | 2 +
.../admin/StreamsGroupSubtopologyDescription.java | 2 +
.../admin/StreamsGroupTopologyDescription.java | 2 +
.../StreamsGroupTopologyDescriptionStatus.java | 2 +
.../kafka/clients/admin/SupportedVersionRange.java | 4 +
.../clients/admin/TerminateTransactionOptions.java | 3 +
.../clients/admin/TerminateTransactionResult.java | 2 +
.../kafka/clients/admin/TopicDescription.java | 2 +
.../apache/kafka/clients/admin/TopicListing.java | 2 +
.../clients/admin/TransactionDescription.java | 3 +
.../kafka/clients/admin/TransactionListing.java | 4 +
.../kafka/clients/admin/TransactionState.java | 4 +
.../clients/admin/UnregisterBrokerOptions.java | 3 +
.../clients/admin/UnregisterBrokerResult.java | 2 +
.../kafka/clients/admin/UpdateFeaturesOptions.java | 4 +
.../kafka/clients/admin/UpdateFeaturesResult.java | 3 +
.../admin/UserScramCredentialAlteration.java | 3 +
.../clients/admin/UserScramCredentialDeletion.java | 3 +
.../admin/UserScramCredentialUpsertion.java | 2 +
.../admin/UserScramCredentialsDescription.java | 3 +
.../kafka/clients/consumer/AcknowledgeType.java | 4 +
.../consumer/AcknowledgementCommitCallback.java | 2 +
.../kafka/clients/consumer/CloseOptions.java | 3 +
.../clients/consumer/CommitFailedException.java | 3 +
.../apache/kafka/clients/consumer/Consumer.java | 2 +
.../kafka/clients/consumer/ConsumerConfig.java | 2 +
.../clients/consumer/ConsumerGroupMetadata.java | 2 +
.../clients/consumer/ConsumerInterceptor.java | 4 +-
.../consumer/ConsumerPartitionAssignor.java | 2 +
.../consumer/ConsumerRebalanceListener.java | 3 +
.../kafka/clients/consumer/ConsumerRecord.java | 2 +
.../kafka/clients/consumer/ConsumerRecords.java | 2 +
.../consumer/CooperativeStickyAssignor.java | 7 +
.../kafka/clients/consumer/GroupProtocol.java | 4 +
.../clients/consumer/InvalidOffsetException.java | 3 +
.../kafka/clients/consumer/KafkaConsumer.java | 2 +
.../kafka/clients/consumer/KafkaShareConsumer.java | 2 +
.../clients/consumer/LogTruncationException.java | 3 +
.../kafka/clients/consumer/MockConsumer.java | 4 +-
.../kafka/clients/consumer/MockShareConsumer.java | 2 +
.../consumer/NoOffsetForPartitionException.java | 3 +
.../kafka/clients/consumer/OffsetAndMetadata.java | 2 +
.../kafka/clients/consumer/OffsetAndTimestamp.java | 4 +
.../clients/consumer/OffsetCommitCallback.java | 3 +
.../consumer/OffsetOutOfRangeException.java | 3 +
.../kafka/clients/consumer/RangeAssignor.java | 5 +
.../kafka/clients/consumer/RebalanceConsumer.java | 4 +-
.../kafka/clients/consumer/RebalanceListener.java | 3 +
.../consumer/RetriableCommitFailedException.java | 2 +
.../kafka/clients/consumer/RoundRobinAssignor.java | 4 +
.../kafka/clients/consumer/ShareConsumer.java | 2 +
.../kafka/clients/consumer/StickyAssignor.java | 7 +
.../clients/consumer/SubscriptionPattern.java | 4 +
.../clients/producer/BufferExhaustedException.java | 2 +
.../apache/kafka/clients/producer/Callback.java | 4 +
.../kafka/clients/producer/KafkaProducer.java | 4 +-
.../kafka/clients/producer/MockProducer.java | 2 +
.../apache/kafka/clients/producer/Partitioner.java | 3 +
.../kafka/clients/producer/PreparedTxnState.java | 2 +
.../apache/kafka/clients/producer/Producer.java | 2 +
.../kafka/clients/producer/ProducerConfig.java | 2 +
.../clients/producer/ProducerInterceptor.java | 2 +
.../kafka/clients/producer/ProducerRecord.java | 2 +
.../kafka/clients/producer/RecordMetadata.java | 2 +
.../clients/producer/RoundRobinPartitioner.java | 2 +
.../org/apache/kafka/common/ClassicGroupState.java | 3 +
.../main/java/org/apache/kafka/common/Cluster.java | 4 +
.../org/apache/kafka/common/ClusterResource.java | 3 +
.../kafka/common/ClusterResourceListener.java | 4 +
.../java/org/apache/kafka/common/Configurable.java | 4 +
.../java/org/apache/kafka/common/ElectionType.java | 3 +
.../java/org/apache/kafka/common/Endpoint.java | 2 +
.../java/org/apache/kafka/common/GroupState.java | 3 +
.../java/org/apache/kafka/common/GroupType.java | 4 +
.../kafka/common/InvalidRecordException.java | 2 +
.../org/apache/kafka/common/IsolationLevel.java | 4 +
.../org/apache/kafka/common/KafkaException.java | 4 +
.../java/org/apache/kafka/common/KafkaFuture.java | 2 +
.../org/apache/kafka/common/MessageFormatter.java | 3 +
.../main/java/org/apache/kafka/common/Metric.java | 4 +
.../java/org/apache/kafka/common/MetricName.java | 4 +
.../apache/kafka/common/MetricNameTemplate.java | 4 +
.../main/java/org/apache/kafka/common/Node.java | 4 +
.../org/apache/kafka/common/PartitionInfo.java | 4 +
.../org/apache/kafka/common/Reconfigurable.java | 2 +
.../org/apache/kafka/common/TopicCollection.java | 4 +
.../org/apache/kafka/common/TopicIdPartition.java | 4 +
.../org/apache/kafka/common/TopicPartition.java | 4 +
.../apache/kafka/common/TopicPartitionInfo.java | 3 +
.../apache/kafka/common/TopicPartitionReplica.java | 5 +-
.../main/java/org/apache/kafka/common/Uuid.java | 4 +
.../kafka/common/acl/AccessControlEntry.java | 3 +
.../kafka/common/acl/AccessControlEntryFilter.java | 3 +
.../org/apache/kafka/common/acl/AclBinding.java | 2 +
.../apache/kafka/common/acl/AclBindingFilter.java | 2 +
.../org/apache/kafka/common/acl/AclOperation.java | 3 +
.../apache/kafka/common/acl/AclPermissionType.java | 3 +
.../kafka/common/annotation/InterfaceAudience.java | 57 ++
.../common/annotation/InterfaceStability.java | 1 +
.../annotation/SuppressKafkaInternalApiUsage.java | 46 ++
.../apache/kafka/common/config/AbstractConfig.java | 2 +
.../org/apache/kafka/common/config/Config.java | 4 +
.../kafka/common/config/ConfigChangeCallback.java | 2 +
.../org/apache/kafka/common/config/ConfigData.java | 2 +
.../org/apache/kafka/common/config/ConfigDef.java | 2 +
.../kafka/common/config/ConfigException.java | 3 +
.../apache/kafka/common/config/ConfigResource.java | 3 +
.../kafka/common/config/ConfigTransformer.java | 2 +
.../common/config/ConfigTransformerResult.java | 2 +
.../apache/kafka/common/config/ConfigValue.java | 4 +
.../apache/kafka/common/config/LogLevelConfig.java | 3 +
.../apache/kafka/common/config/SaslConfigs.java | 2 +
.../apache/kafka/common/config/SecurityConfig.java | 4 +
.../apache/kafka/common/config/SslClientAuth.java | 3 +
.../org/apache/kafka/common/config/SslConfigs.java | 2 +
.../apache/kafka/common/config/TopicConfig.java | 3 +
.../common/config/provider/ConfigProvider.java | 2 +
.../config/provider/DirectoryConfigProvider.java | 4 +-
.../config/provider/EnvVarConfigProvider.java | 2 +
.../common/config/provider/FileConfigProvider.java | 2 +
.../apache/kafka/common/config/types/Password.java | 4 +
.../apache/kafka/common/errors/ApiException.java | 3 +
.../errors/ApplicationRecoverableException.java | 4 +
.../common/errors/AuthenticationException.java | 4 +
.../common/errors/AuthorizationException.java | 4 +
.../common/errors/AuthorizerNotReadyException.java | 4 +
.../errors/BrokerIdNotRegisteredException.java | 4 +
.../common/errors/BrokerNotAvailableException.java | 4 +
.../errors/ClusterAuthorizationException.java | 4 +
.../errors/ConcurrentTransactionsException.java | 4 +
.../common/errors/ControllerMovedException.java | 4 +
.../errors/CoordinatorLoadInProgressException.java | 4 +
.../errors/CoordinatorNotAvailableException.java | 4 +
.../common/errors/CorruptRecordException.java | 4 +
.../DelegationTokenAuthorizationException.java | 4 +
.../errors/DelegationTokenDisabledException.java | 4 +
.../errors/DelegationTokenExpiredException.java | 4 +
.../errors/DelegationTokenNotFoundException.java | 4 +
.../DelegationTokenOwnerMismatchException.java | 4 +
.../kafka/common/errors/DisconnectException.java | 3 +
.../DuplicateBrokerRegistrationException.java | 4 +
.../common/errors/DuplicateResourceException.java | 4 +
.../common/errors/DuplicateSequenceException.java | 4 +
.../common/errors/DuplicateVoterException.java | 4 +
.../common/errors/ElectionNotNeededException.java | 4 +
.../EligibleLeadersNotAvailableException.java | 4 +
.../errors/FeatureUpdateFailedException.java | 4 +
.../common/errors/FencedInstanceIdException.java | 4 +
.../common/errors/FencedLeaderEpochException.java | 4 +
.../common/errors/FencedMemberEpochException.java | 4 +
.../common/errors/FencedStateEpochException.java | 4 +
.../errors/FetchSessionIdNotFoundException.java | 3 +
.../errors/FetchSessionTopicIdException.java | 4 +
.../common/errors/GroupAuthorizationException.java | 4 +
.../errors/GroupDeletionFailedException.java | 4 +
.../common/errors/GroupIdNotFoundException.java | 4 +
.../errors/GroupMaxSizeReachedException.java | 4 +
.../common/errors/GroupNotEmptyException.java | 4 +
.../errors/GroupSubscribedToTopicException.java | 4 +
.../common/errors/IllegalGenerationException.java | 4 +
.../common/errors/IllegalSaslStateException.java | 4 +
.../errors/InconsistentClusterIdException.java | 4 +
.../errors/InconsistentGroupProtocolException.java | 4 +
.../errors/InconsistentTopicIdException.java | 4 +
.../errors/InconsistentVoterSetException.java | 4 +
.../common/errors/IneligibleReplicaException.java | 4 +
.../kafka/common/errors/InterruptException.java | 3 +
.../errors/InvalidCommitOffsetSizeException.java | 4 +
.../errors/InvalidConfigurationException.java | 4 +
.../errors/InvalidFetchSessionEpochException.java | 3 +
.../common/errors/InvalidFetchSizeException.java | 4 +
.../common/errors/InvalidGroupIdException.java | 4 +
.../common/errors/InvalidMetadataException.java | 4 +
.../common/errors/InvalidOffsetException.java | 4 +
.../common/errors/InvalidPartitionsException.java | 4 +
.../common/errors/InvalidPidMappingException.java | 4 +
.../errors/InvalidPrincipalTypeException.java | 4 +
.../errors/InvalidProducerEpochException.java | 4 +
.../common/errors/InvalidRecordStateException.java | 4 +
.../errors/InvalidRegistrationException.java | 4 +
.../common/errors/InvalidRegularExpression.java | 3 +
.../errors/InvalidReplicaAssignmentException.java | 4 +
.../errors/InvalidReplicationFactorException.java | 4 +
.../common/errors/InvalidRequestException.java | 4 +
.../errors/InvalidRequiredAcksException.java | 4 +
.../errors/InvalidSessionTimeoutException.java | 4 +
.../errors/InvalidShareSessionEpochException.java | 4 +
.../common/errors/InvalidTimestampException.java | 4 +
.../kafka/common/errors/InvalidTopicException.java | 5 +-
.../common/errors/InvalidTxnStateException.java | 4 +
.../common/errors/InvalidTxnTimeoutException.java | 4 +
.../errors/InvalidUpdateVersionException.java | 4 +
.../common/errors/InvalidVoterKeyException.java | 4 +
.../kafka/common/errors/KafkaStorageException.java | 4 +
.../common/errors/LeaderNotAvailableException.java | 4 +
.../common/errors/ListenerNotFoundException.java | 4 +
.../common/errors/LogDirNotFoundException.java | 4 +
.../common/errors/MemberIdRequiredException.java | 4 +
.../errors/MismatchedEndpointTypeException.java | 4 +
.../kafka/common/errors/NetworkException.java | 4 +
.../common/errors/NewLeaderElectedException.java | 4 +
.../errors/NoReassignmentInProgressException.java | 3 +
.../common/errors/NotControllerException.java | 4 +
.../common/errors/NotCoordinatorException.java | 4 +
.../NotEnoughReplicasAfterAppendException.java | 4 +
.../common/errors/NotEnoughReplicasException.java | 4 +
.../errors/NotLeaderOrFollowerException.java | 4 +
.../common/errors/OffsetMetadataTooLarge.java | 4 +
.../OffsetMovedToTieredStorageException.java | 4 +
.../common/errors/OffsetNotAvailableException.java | 4 +
.../common/errors/OffsetOutOfRangeException.java | 4 +
.../errors/OperationNotAttemptedException.java | 4 +
.../common/errors/OutOfOrderSequenceException.java | 4 +
.../common/errors/PolicyViolationException.java | 4 +
.../common/errors/PositionOutOfRangeException.java | 4 +
.../PreferredLeaderNotAvailableException.java | 4 +
.../errors/PrincipalDeserializationException.java | 4 +
.../common/errors/ProducerFencedException.java | 4 +
.../errors/ReassignmentInProgressException.java | 3 +
.../errors/RebalanceInProgressException.java | 4 +
.../errors/RebootstrapRequiredException.java | 3 +
.../errors/RecordBatchTooLargeException.java | 4 +
.../errors/RecordDeserializationException.java | 2 +
.../common/errors/RecordTooLargeException.java | 3 +
.../common/errors/RefreshRetriableException.java | 4 +
.../errors/ReplicaNotAvailableException.java | 4 +
.../common/errors/ResourceNotFoundException.java | 4 +
.../kafka/common/errors/RetriableException.java | 4 +
.../common/errors/SaslAuthenticationException.java | 4 +
.../common/errors/SecurityDisabledException.java | 4 +
.../common/errors/SerializationException.java | 3 +
.../errors/ShareSessionLimitReachedException.java | 4 +
.../errors/ShareSessionNotFoundException.java | 4 +
.../common/errors/SnapshotNotFoundException.java | 4 +
.../common/errors/SslAuthenticationException.java | 4 +
.../common/errors/StaleBrokerEpochException.java | 4 +
.../common/errors/StaleMemberEpochException.java | 4 +
.../StreamsInvalidTopologyEpochException.java | 4 +
.../errors/StreamsInvalidTopologyException.java | 4 +
...msTopologyDescriptionUpdateFailedException.java | 4 +
.../errors/StreamsTopologyFencedException.java | 4 +
.../common/errors/TelemetryTooLargeException.java | 4 +
.../errors/ThrottlingQuotaExceededException.java | 4 +
.../kafka/common/errors/TimeoutException.java | 4 +
.../common/errors/TopicAuthorizationException.java | 4 +
.../errors/TopicDeletionDisabledException.java | 3 +
.../kafka/common/errors/TopicExistsException.java | 4 +
.../errors/TransactionAbortableException.java | 4 +
.../common/errors/TransactionAbortedException.java | 4 +
.../TransactionCoordinatorFencedException.java | 4 +
.../TransactionalIdAuthorizationException.java | 4 +
.../errors/TransactionalIdNotFoundException.java | 4 +
.../errors/UnacceptableCredentialException.java | 4 +
.../errors/UnknownControllerIdException.java | 4 +
.../common/errors/UnknownLeaderEpochException.java | 4 +
.../common/errors/UnknownMemberIdException.java | 4 +
.../common/errors/UnknownProducerIdException.java | 3 +
.../common/errors/UnknownServerException.java | 4 +
.../errors/UnknownSubscriptionIdException.java | 4 +
.../common/errors/UnknownTopicIdException.java | 4 +
.../errors/UnknownTopicOrPartitionException.java | 4 +
.../errors/UnreleasedInstanceIdException.java | 4 +
.../errors/UnstableOffsetCommitException.java | 4 +
.../errors/UnsupportedAssignorException.java | 4 +
.../UnsupportedByAuthenticationException.java | 4 +
.../UnsupportedCompressionTypeException.java | 4 +
.../errors/UnsupportedEndpointTypeException.java | 4 +
.../UnsupportedForMessageFormatException.java | 4 +
.../errors/UnsupportedSaslMechanismException.java | 4 +
.../common/errors/UnsupportedVersionException.java | 4 +
.../common/errors/VoterNotFoundException.java | 4 +
.../kafka/common/errors/WakeupException.java | 3 +
.../org/apache/kafka/common/header/Header.java | 4 +
.../org/apache/kafka/common/header/Headers.java | 3 +
.../apache/kafka/common/metrics/CompoundStat.java | 3 +
.../org/apache/kafka/common/metrics/Gauge.java | 4 +
.../apache/kafka/common/metrics/JmxReporter.java | 2 +
.../apache/kafka/common/metrics/KafkaMetric.java | 4 +
.../kafka/common/metrics/KafkaMetricsContext.java | 4 +
.../apache/kafka/common/metrics/Measurable.java | 4 +
.../kafka/common/metrics/MeasurableStat.java | 4 +
.../apache/kafka/common/metrics/MetricConfig.java | 4 +
.../kafka/common/metrics/MetricValueProvider.java | 4 +
.../org/apache/kafka/common/metrics/Metrics.java | 9 +
.../kafka/common/metrics/MetricsContext.java | 4 +
.../kafka/common/metrics/MetricsReporter.java | 2 +
.../apache/kafka/common/metrics/Monitorable.java | 4 +
.../apache/kafka/common/metrics/PluginMetrics.java | 3 +
.../org/apache/kafka/common/metrics/Quota.java | 4 +
.../common/metrics/QuotaViolationException.java | 3 +
.../org/apache/kafka/common/metrics/Sensor.java | 2 +
.../java/org/apache/kafka/common/metrics/Stat.java | 4 +
.../org/apache/kafka/common/metrics/stats/Avg.java | 2 +
.../common/metrics/stats/CumulativeCount.java | 2 +
.../kafka/common/metrics/stats/CumulativeSum.java | 2 +
.../kafka/common/metrics/stats/Frequencies.java | 2 +
.../kafka/common/metrics/stats/Frequency.java | 4 +-
.../kafka/common/metrics/stats/Histogram.java | 4 +
.../org/apache/kafka/common/metrics/stats/Max.java | 2 +
.../apache/kafka/common/metrics/stats/Meter.java | 4 +-
.../org/apache/kafka/common/metrics/stats/Min.java | 2 +
.../kafka/common/metrics/stats/Percentile.java | 3 +
.../kafka/common/metrics/stats/Percentiles.java | 2 +
.../apache/kafka/common/metrics/stats/Rate.java | 2 +
.../kafka/common/metrics/stats/SampledStat.java | 2 +
.../kafka/common/metrics/stats/SimpleRate.java | 2 +
.../kafka/common/metrics/stats/TokenBucket.java | 2 +
.../apache/kafka/common/metrics/stats/Value.java | 2 +
.../kafka/common/metrics/stats/WindowedCount.java | 2 +
.../kafka/common/metrics/stats/WindowedSum.java | 2 +
.../kafka/common/quota/ClientQuotaAlteration.java | 3 +
.../kafka/common/quota/ClientQuotaEntity.java | 3 +
.../kafka/common/quota/ClientQuotaFilter.java | 3 +
.../common/quota/ClientQuotaFilterComponent.java | 3 +
.../apache/kafka/common/record/TimestampType.java | 4 +
.../apache/kafka/common/resource/PatternType.java | 3 +
.../org/apache/kafka/common/resource/Resource.java | 3 +
.../kafka/common/resource/ResourcePattern.java | 3 +
.../common/resource/ResourcePatternFilter.java | 3 +
.../apache/kafka/common/resource/ResourceType.java | 3 +
.../security/auth/AuthenticateCallbackHandler.java | 3 +
.../security/auth/AuthenticationContext.java | 5 +-
.../kafka/common/security/auth/KafkaPrincipal.java | 3 +
.../security/auth/KafkaPrincipalBuilder.java | 4 +
.../common/security/auth/KafkaPrincipalSerde.java | 2 +
.../apache/kafka/common/security/auth/Login.java | 4 +
.../auth/PlaintextAuthenticationContext.java | 4 +
.../security/auth/SaslAuthenticationContext.java | 4 +
.../kafka/common/security/auth/SaslExtensions.java | 4 +
.../security/auth/SaslExtensionsCallback.java | 3 +
.../common/security/auth/SecurityProtocol.java | 4 +
.../security/auth/SecurityProviderCreator.java | 3 +
.../security/auth/SslAuthenticationContext.java | 4 +
.../common/security/auth/SslEngineFactory.java | 3 +
.../security/oauthbearer/BrokerJwtValidator.java | 2 +
.../oauthbearer/ClientCredentialsJwtRetriever.java | 4 +
.../security/oauthbearer/ClientJwtValidator.java | 2 +
.../security/oauthbearer/DefaultJwtRetriever.java | 2 +
.../security/oauthbearer/DefaultJwtValidator.java | 4 +
.../security/oauthbearer/FileJwtRetriever.java | 2 +
.../oauthbearer/JwtBearerJwtRetriever.java | 4 +
.../common/security/oauthbearer/JwtRetriever.java | 5 +-
.../oauthbearer/JwtRetrieverException.java | 2 +
.../common/security/oauthbearer/JwtValidator.java | 5 +-
.../oauthbearer/JwtValidatorException.java | 2 +
.../OAuthBearerExtensionsValidatorCallback.java | 2 +
.../OAuthBearerLoginCallbackHandler.java | 2 +
.../oauthbearer/OAuthBearerLoginModule.java | 2 +
.../security/oauthbearer/OAuthBearerToken.java | 4 +
.../oauthbearer/OAuthBearerTokenCallback.java | 4 +
.../oauthbearer/OAuthBearerValidatorCallback.java | 4 +
.../OAuthBearerValidatorCallbackHandler.java | 2 +
.../security/plain/PlainAuthenticateCallback.java | 3 +
.../common/security/plain/PlainLoginModule.java | 2 +
.../common/security/scram/ScramCredential.java | 4 +
.../security/scram/ScramCredentialCallback.java | 4 +
.../security/scram/ScramExtensionsCallback.java | 5 +-
.../common/security/scram/ScramLoginModule.java | 2 +
.../security/token/delegation/DelegationToken.java | 4 +
.../token/delegation/TokenInformation.java | 2 +
.../common/serialization/BooleanDeserializer.java | 2 +
.../common/serialization/BooleanSerializer.java | 4 +
.../serialization/ByteArrayDeserializer.java | 4 +
.../common/serialization/ByteArraySerializer.java | 4 +
.../serialization/ByteBufferDeserializer.java | 2 +
.../common/serialization/ByteBufferSerializer.java | 4 +
.../common/serialization/BytesDeserializer.java | 2 +
.../common/serialization/BytesSerializer.java | 2 +
.../kafka/common/serialization/Deserializer.java | 2 +
.../common/serialization/DoubleDeserializer.java | 2 +
.../common/serialization/DoubleSerializer.java | 4 +
.../common/serialization/FloatDeserializer.java | 2 +
.../common/serialization/FloatSerializer.java | 4 +
.../common/serialization/IntegerDeserializer.java | 2 +
.../common/serialization/IntegerSerializer.java | 4 +
.../common/serialization/ListDeserializer.java | 2 +
.../kafka/common/serialization/ListSerializer.java | 2 +
.../common/serialization/LongDeserializer.java | 2 +
.../kafka/common/serialization/LongSerializer.java | 4 +
.../apache/kafka/common/serialization/Serde.java | 4 +
.../apache/kafka/common/serialization/Serdes.java | 2 +
.../kafka/common/serialization/Serializer.java | 2 +
.../common/serialization/ShortDeserializer.java | 2 +
.../common/serialization/ShortSerializer.java | 4 +
.../common/serialization/StringDeserializer.java | 2 +
.../common/serialization/StringSerializer.java | 2 +
.../common/serialization/UUIDDeserializer.java | 2 +
.../kafka/common/serialization/UUIDSerializer.java | 2 +
.../common/serialization/VoidDeserializer.java | 2 +
.../kafka/common/serialization/VoidSerializer.java | 4 +
.../java/org/apache/kafka/common/utils/Bytes.java | 2 +
.../kafka/server/authorizer/AclCreateResult.java | 2 +
.../kafka/server/authorizer/AclDeleteResult.java | 2 +
.../org/apache/kafka/server/authorizer/Action.java | 2 +
.../authorizer/AuthorizableRequestContext.java | 2 +
.../server/authorizer/AuthorizationResult.java | 3 +
.../apache/kafka/server/authorizer/Authorizer.java | 2 +
.../server/authorizer/AuthorizerServerInfo.java | 2 +
.../kafka/server/policy/AlterConfigPolicy.java | 2 +
.../kafka/server/policy/CreateTopicPolicy.java | 2 +
.../kafka/server/quota/ClientQuotaCallback.java | 2 +
.../kafka/server/quota/ClientQuotaEntity.java | 4 +
.../apache/kafka/server/quota/ClientQuotaType.java | 4 +
.../server/telemetry/ClientTelemetryContext.java | 2 +
.../server/telemetry/ClientTelemetryExporter.java | 3 +
.../telemetry/ClientTelemetryExporterProvider.java | 2 +
.../server/telemetry/ClientTelemetryPayload.java | 2 +
.../kafka/connect/components/ConnectPlugin.java | 2 +
.../apache/kafka/connect/components/Versioned.java | 3 +
.../kafka/connect/connector/ConnectRecord.java | 2 +
.../apache/kafka/connect/connector/Connector.java | 2 +
.../kafka/connect/connector/ConnectorContext.java | 2 +
.../org/apache/kafka/connect/connector/Task.java | 4 +
.../ConnectorClientConfigOverridePolicy.java | 2 +
.../policy/ConnectorClientConfigRequest.java | 2 +
.../apache/kafka/connect/data/ConnectSchema.java | 2 +
.../java/org/apache/kafka/connect/data/Date.java | 2 +
.../org/apache/kafka/connect/data/Decimal.java | 2 +
.../java/org/apache/kafka/connect/data/Field.java | 4 +
.../java/org/apache/kafka/connect/data/Schema.java | 2 +
.../apache/kafka/connect/data/SchemaAndValue.java | 4 +
.../apache/kafka/connect/data/SchemaBuilder.java | 2 +
.../apache/kafka/connect/data/SchemaProjector.java | 2 +
.../java/org/apache/kafka/connect/data/Struct.java | 2 +
.../java/org/apache/kafka/connect/data/Time.java | 2 +
.../org/apache/kafka/connect/data/Timestamp.java | 2 +
.../java/org/apache/kafka/connect/data/Values.java | 2 +
.../connect/errors/AlreadyExistsException.java | 4 +
.../kafka/connect/errors/ConnectException.java | 3 +
.../apache/kafka/connect/errors/DataException.java | 4 +
.../errors/IllegalWorkerStateException.java | 4 +
.../kafka/connect/errors/NotFoundException.java | 4 +
.../kafka/connect/errors/RetriableException.java | 4 +
.../connect/errors/SchemaBuilderException.java | 4 +
.../connect/errors/SchemaProjectorException.java | 4 +
.../kafka/connect/header/ConnectHeaders.java | 2 +
.../org/apache/kafka/connect/header/Header.java | 2 +
.../org/apache/kafka/connect/header/Headers.java | 2 +
.../apache/kafka/connect/health/AbstractState.java | 2 +
.../connect/health/ConnectClusterDetails.java | 3 +
.../kafka/connect/health/ConnectClusterState.java | 3 +
.../kafka/connect/health/ConnectorHealth.java | 2 +
.../kafka/connect/health/ConnectorState.java | 3 +
.../apache/kafka/connect/health/ConnectorType.java | 4 +
.../org/apache/kafka/connect/health/TaskState.java | 3 +
.../kafka/connect/rest/ConnectRestExtension.java | 2 +
.../connect/rest/ConnectRestExtensionContext.java | 3 +-
.../kafka/connect/sink/ErrantRecordReporter.java | 2 +
.../apache/kafka/connect/sink/SinkConnector.java | 2 +
.../kafka/connect/sink/SinkConnectorContext.java | 2 +
.../org/apache/kafka/connect/sink/SinkRecord.java | 2 +
.../org/apache/kafka/connect/sink/SinkTask.java | 2 +
.../apache/kafka/connect/sink/SinkTaskContext.java | 2 +
.../source/ConnectorTransactionBoundaries.java | 4 +
.../kafka/connect/source/ExactlyOnceSupport.java | 4 +
.../kafka/connect/source/SourceConnector.java | 2 +
.../connect/source/SourceConnectorContext.java | 2 +
.../apache/kafka/connect/source/SourceRecord.java | 2 +
.../apache/kafka/connect/source/SourceTask.java | 2 +
.../kafka/connect/source/SourceTaskContext.java | 2 +
.../kafka/connect/source/TransactionContext.java | 4 +
.../apache/kafka/connect/storage/Converter.java | 2 +
.../kafka/connect/storage/ConverterConfig.java | 2 +
.../kafka/connect/storage/ConverterType.java | 4 +
.../kafka/connect/storage/HeaderConverter.java | 2 +
.../kafka/connect/storage/OffsetStorageReader.java | 4 +
.../connect/storage/SimpleHeaderConverter.java | 2 +
.../kafka/connect/storage/StringConverter.java | 2 +
.../connect/storage/StringConverterConfig.java | 2 +
.../kafka/connect/transforms/Transformation.java | 2 +
.../connect/transforms/predicates/Predicate.java | 2 +
.../apache/kafka/connect/util/ConnectorUtils.java | 4 +
.../apache/kafka/connect/mirror/Checkpoint.java | 6 +
.../connect/mirror/DefaultReplicationPolicy.java | 2 +
.../org/apache/kafka/connect/mirror/Heartbeat.java | 6 +
.../connect/mirror/IdentityReplicationPolicy.java | 3 +
.../apache/kafka/connect/mirror/MirrorClient.java | 2 +
.../kafka/connect/mirror/MirrorClientConfig.java | 2 +
.../kafka/connect/mirror/RemoteClusterUtils.java | 3 +-
.../kafka/connect/mirror/ReplicationPolicy.java | 3 +
.../kafka/connect/mirror/SourceAndTarget.java | 4 +
.../apache/kafka/connect/tools/MockConnector.java | 2 +
.../kafka/connect/tools/MockSinkConnector.java | 2 +
.../apache/kafka/connect/tools/MockSinkTask.java | 2 +
.../kafka/connect/tools/MockSourceConnector.java | 2 +
.../apache/kafka/connect/tools/MockSourceTask.java | 2 +
.../kafka/connect/tools/SchemaSourceConnector.java | 2 +
.../kafka/connect/tools/SchemaSourceTask.java | 2 +
.../connect/tools/VerifiableSinkConnector.java | 2 +
.../kafka/connect/tools/VerifiableSinkTask.java | 2 +
.../connect/tools/VerifiableSourceConnector.java | 2 +
.../kafka/connect/tools/VerifiableSourceTask.java | 2 +
docs/apis/internal-api-checker.md | 187 ++++++
.../assignor/ConsumerGroupPartitionAssignor.java | 4 +
.../group/api/assignor/GroupAssignment.java | 4 +
.../coordinator/group/api/assignor/GroupSpec.java | 3 +
.../group/api/assignor/MemberAssignment.java | 3 +
.../group/api/assignor/MemberSubscription.java | 3 +
.../group/api/assignor/PartitionAssignor.java | 4 +
.../api/assignor/PartitionAssignorException.java | 2 +
.../api/assignor/ShareGroupPartitionAssignor.java | 4 +
.../api/assignor/SubscribedTopicDescriber.java | 3 +
.../group/api/assignor/SubscriptionType.java | 4 +
.../streams/StreamsGroupTopologyDescription.java | 2 +
.../StreamsGroupTopologyDescriptionPlugin.java | 2 +
...pologyDescriptionPermanentFailureException.java | 2 +
...pologyDescriptionTransientFailureException.java | 2 +
release/release.py | 19 +
.../kafka/server/share/fetch/InFlightState.java | 2 +-
settings.gradle | 9 +
.../server/log/remote/storage/LogSegmentData.java | 4 +
.../log/remote/storage/RemoteLogMetadata.java | 3 +
.../remote/storage/RemoteLogMetadataManager.java | 3 +
.../log/remote/storage/RemoteLogSegmentId.java | 3 +
.../remote/storage/RemoteLogSegmentMetadata.java | 3 +
.../storage/RemoteLogSegmentMetadataUpdate.java | 2 +
.../log/remote/storage/RemoteLogSegmentState.java | 4 +
.../storage/RemotePartitionDeleteMetadata.java | 3 +
.../remote/storage/RemotePartitionDeleteState.java | 4 +
.../storage/RemoteResourceNotFoundException.java | 4 +
.../log/remote/storage/RemoteStorageException.java | 4 +
.../log/remote/storage/RemoteStorageManager.java | 2 +
.../log/remote/storage/RemoteStorageMetrics.java | 2 +
.../storage/RemoteStorageNotReadyException.java | 4 +
.../storage/RetriableRemoteStorageException.java | 4 +
.../remote/storage/LocalTieredStorageListener.java | 2 +-
.../org/apache/kafka/streams/AutoOffsetReset.java | 4 +
.../apache/kafka/streams/ClientInstanceIds.java | 3 +
.../org/apache/kafka/streams/CloseOptions.java | 4 +
.../org/apache/kafka/streams/DslStoreFormat.java | 4 +
.../org/apache/kafka/streams/GroupProtocol.java | 4 +
.../apache/kafka/streams/KafkaClientSupplier.java | 2 +
.../org/apache/kafka/streams/KafkaStreams.java | 13 +
.../org/apache/kafka/streams/KeyQueryMetadata.java | 2 +
.../java/org/apache/kafka/streams/KeyValue.java | 4 +
.../java/org/apache/kafka/streams/LagInfo.java | 4 +
.../apache/kafka/streams/StoreQueryParameters.java | 2 +
.../org/apache/kafka/streams/StreamsBuilder.java | 5 +
.../org/apache/kafka/streams/StreamsConfig.java | 2 +
.../org/apache/kafka/streams/StreamsMetadata.java | 2 +
.../org/apache/kafka/streams/StreamsMetrics.java | 2 +
.../org/apache/kafka/streams/TaskMetadata.java | 4 +-
.../org/apache/kafka/streams/ThreadMetadata.java | 4 +
.../java/org/apache/kafka/streams/Topology.java | 5 +
.../org/apache/kafka/streams/TopologyConfig.java | 2 +
.../apache/kafka/streams/TopologyDescription.java | 2 +
.../errors/DefaultProductionExceptionHandler.java | 2 +
.../errors/DeserializationExceptionHandler.java | 2 +
.../kafka/streams/errors/ErrorHandlerContext.java | 2 +
.../InternalTopicsAlreadySetupException.java | 4 +
.../streams/errors/InvalidStateStoreException.java | 4 +
.../InvalidStateStorePartitionException.java | 2 +
.../apache/kafka/streams/errors/LockException.java | 4 +
.../errors/LogAndContinueExceptionHandler.java | 2 +
.../LogAndContinueProcessingExceptionHandler.java | 2 +
.../streams/errors/LogAndFailExceptionHandler.java | 2 +
.../LogAndFailProcessingExceptionHandler.java | 2 +
.../MisconfiguredInternalTopicException.java | 4 +
.../errors/MissingInternalTopicsException.java | 4 +
.../errors/MissingSourceTopicException.java | 4 +
.../streams/errors/ProcessingExceptionHandler.java | 2 +
.../streams/errors/ProcessorStateException.java | 4 +
.../streams/errors/ProductionExceptionHandler.java | 3 +
.../errors/StateStoreMigratedException.java | 4 +
.../errors/StateStoreNotAvailableException.java | 4 +
.../kafka/streams/errors/StreamsException.java | 2 +
.../streams/errors/StreamsNotStartedException.java | 2 +
.../errors/StreamsRebalancingException.java | 4 +
.../streams/errors/StreamsStoppedException.java | 2 +
.../errors/StreamsUncaughtExceptionHandler.java | 4 +
.../streams/errors/TaskAssignmentException.java | 4 +
.../streams/errors/TaskCorruptedException.java | 2 +
.../streams/errors/TaskIdFormatException.java | 3 +
.../streams/errors/TaskMigratedException.java | 3 +
.../kafka/streams/errors/TopologyException.java | 4 +
.../streams/errors/UnknownStateStoreException.java | 4 +
.../streams/errors/UnknownTopologyException.java | 4 +
.../apache/kafka/streams/kstream/Aggregator.java | 3 +
.../org/apache/kafka/streams/kstream/Branched.java | 4 +
.../kafka/streams/kstream/BranchedKStream.java | 2 +
.../kafka/streams/kstream/CogroupedKStream.java | 2 +
.../org/apache/kafka/streams/kstream/Consumed.java | 2 +
.../apache/kafka/streams/kstream/EmitStrategy.java | 2 +
.../kafka/streams/kstream/ForeachAction.java | 3 +
.../apache/kafka/streams/kstream/GlobalKTable.java | 2 +
.../org/apache/kafka/streams/kstream/Grouped.java | 2 +
.../apache/kafka/streams/kstream/Initializer.java | 4 +
.../apache/kafka/streams/kstream/JoinWindows.java | 2 +
.../org/apache/kafka/streams/kstream/Joined.java | 2 +
.../kafka/streams/kstream/KGroupedStream.java | 2 +
.../kafka/streams/kstream/KGroupedTable.java | 2 +
.../org/apache/kafka/streams/kstream/KStream.java | 2 +
.../org/apache/kafka/streams/kstream/KTable.java | 2 +
.../kafka/streams/kstream/KeyValueMapper.java | 2 +
.../apache/kafka/streams/kstream/Materialized.java | 2 +
.../org/apache/kafka/streams/kstream/Merger.java | 3 +
.../org/apache/kafka/streams/kstream/Named.java | 2 +
.../apache/kafka/streams/kstream/Predicate.java | 2 +
.../org/apache/kafka/streams/kstream/Printed.java | 2 +
.../org/apache/kafka/streams/kstream/Produced.java | 2 +
.../org/apache/kafka/streams/kstream/Reducer.java | 2 +
.../kafka/streams/kstream/Repartitioned.java | 2 +
.../kstream/SessionWindowedCogroupedKStream.java | 4 +-
.../kstream/SessionWindowedDeserializer.java | 2 +
.../streams/kstream/SessionWindowedKStream.java | 2 +
.../streams/kstream/SessionWindowedSerializer.java | 4 +
.../kafka/streams/kstream/SessionWindows.java | 2 +
.../kafka/streams/kstream/SlidingWindows.java | 2 +
.../apache/kafka/streams/kstream/StreamJoined.java | 2 +
.../apache/kafka/streams/kstream/Suppressed.java | 2 +
.../apache/kafka/streams/kstream/TableJoined.java | 2 +
.../kstream/TimeWindowedCogroupedKStream.java | 4 +-
.../streams/kstream/TimeWindowedDeserializer.java | 2 +
.../kafka/streams/kstream/TimeWindowedKStream.java | 2 +
.../streams/kstream/TimeWindowedSerializer.java | 4 +
.../apache/kafka/streams/kstream/TimeWindows.java | 5 +
.../kafka/streams/kstream/UnlimitedWindows.java | 5 +
.../apache/kafka/streams/kstream/ValueJoiner.java | 3 +
.../kafka/streams/kstream/ValueJoinerWithKey.java | 3 +
.../apache/kafka/streams/kstream/ValueMapper.java | 2 +
.../kafka/streams/kstream/ValueMapperWithKey.java | 4 +
.../streams/kstream/ValueTransformerWithKey.java | 2 +
.../kstream/ValueTransformerWithKeySupplier.java | 2 +
.../org/apache/kafka/streams/kstream/Window.java | 2 +
.../org/apache/kafka/streams/kstream/Windowed.java | 3 +
.../kafka/streams/kstream/WindowedSerdes.java | 2 +
.../org/apache/kafka/streams/kstream/Windows.java | 2 +
.../processor/BatchingStateRestoreCallback.java | 3 +-
.../kafka/streams/processor/Cancellable.java | 4 +
.../kafka/streams/processor/CommitCallback.java | 2 +
.../streams/processor/ConnectedStoreProvider.java | 2 +
.../streams/processor/FailOnInvalidTimestamp.java | 2 +
.../processor/LogAndSkipOnInvalidTimestamp.java | 2 +
.../kafka/streams/processor/ProcessorContext.java | 2 +
.../kafka/streams/processor/PunctuationType.java | 4 +
.../apache/kafka/streams/processor/Punctuator.java | 2 +
.../kafka/streams/processor/RecordContext.java | 2 +
.../streams/processor/StandbyUpdateListener.java | 3 +
.../streams/processor/StateRestoreCallback.java | 4 +
.../streams/processor/StateRestoreListener.java | 2 +
.../apache/kafka/streams/processor/StateStore.java | 2 +
.../kafka/streams/processor/StateStoreContext.java | 2 +
.../kafka/streams/processor/StreamPartitioner.java | 2 +
.../org/apache/kafka/streams/processor/TaskId.java | 2 +
.../streams/processor/TimestampExtractor.java | 2 +
.../org/apache/kafka/streams/processor/To.java | 4 +
.../streams/processor/TopicNameExtractor.java | 4 +
.../UsePartitionTimeOnInvalidTimestamp.java | 2 +
.../processor/WallclockTimestampExtractor.java | 3 +
.../processor/api/ContextualFixedKeyProcessor.java | 4 +
.../streams/processor/api/ContextualProcessor.java | 4 +
.../streams/processor/api/FixedKeyProcessor.java | 2 +
.../processor/api/FixedKeyProcessorContext.java | 4 +
.../processor/api/FixedKeyProcessorSupplier.java | 2 +
.../streams/processor/api/FixedKeyRecord.java | 2 +
.../api/InternalFixedKeyRecordFactory.java | 4 +
.../streams/processor/api/ProcessingContext.java | 2 +
.../kafka/streams/processor/api/Processor.java | 2 +
.../streams/processor/api/ProcessorContext.java | 4 +
.../streams/processor/api/ProcessorSupplier.java | 2 +
.../streams/processor/api/ProcessorWrapper.java | 2 +
.../streams/processor/api/ReadOnlyRecord.java | 2 +
.../apache/kafka/streams/processor/api/Record.java | 2 +
.../streams/processor/api/RecordMetadata.java | 2 +
.../api/WrappedFixedKeyProcessorSupplier.java | 3 +
.../processor/api/WrappedProcessorSupplier.java | 3 +
.../processor/assignment/ApplicationState.java | 2 +
.../processor/assignment/AssignmentConfigs.java | 2 +
.../assignment/KafkaStreamsAssignment.java | 2 +
.../processor/assignment/KafkaStreamsState.java | 2 +
.../streams/processor/assignment/ProcessId.java | 4 +
.../processor/assignment/TaskAssignmentUtils.java | 2 +
.../streams/processor/assignment/TaskAssignor.java | 2 +
.../streams/processor/assignment/TaskInfo.java | 4 +-
.../processor/assignment/TaskTopicPartition.java | 3 +
.../assignment/assignors/StickyTaskAssignor.java | 4 +-
.../apache/kafka/streams/query/FailureReason.java | 4 +-
.../org/apache/kafka/streams/query/KeyQuery.java | 2 +
.../streams/query/MultiVersionedKeyQuery.java | 2 +
.../org/apache/kafka/streams/query/Position.java | 4 +-
.../apache/kafka/streams/query/PositionBound.java | 4 +-
.../java/org/apache/kafka/streams/query/Query.java | 4 +
.../apache/kafka/streams/query/QueryConfig.java | 2 +
.../apache/kafka/streams/query/QueryResult.java | 2 +
.../org/apache/kafka/streams/query/RangeQuery.java | 4 +-
.../apache/kafka/streams/query/ResultOrder.java | 3 +
.../kafka/streams/query/StateQueryRequest.java | 2 +
.../kafka/streams/query/StateQueryResult.java | 2 +
.../kafka/streams/query/TimestampedKeyQuery.java | 2 +
.../kafka/streams/query/TimestampedRangeQuery.java | 4 +-
.../kafka/streams/query/VersionedKeyQuery.java | 2 +
.../apache/kafka/streams/query/WindowKeyQuery.java | 2 +
.../kafka/streams/query/WindowRangeQuery.java | 2 +
.../streams/state/AggregationWithHeaders.java | 2 +
.../streams/state/BuiltInDslStoreSuppliers.java | 2 +
.../kafka/streams/state/DslKeyValueParams.java | 2 +
.../kafka/streams/state/DslSessionParams.java | 2 +
.../kafka/streams/state/DslStoreSuppliers.java | 2 +
.../kafka/streams/state/DslWindowParams.java | 2 +
.../kafka/streams/state/HeadersBytesStore.java | 3 +
.../streams/state/HeadersBytesStoreSupplier.java | 4 +
.../org/apache/kafka/streams/state/HostInfo.java | 2 +
.../streams/state/KeyValueBytesStoreSupplier.java | 2 +
.../kafka/streams/state/KeyValueIterator.java | 2 +
.../apache/kafka/streams/state/KeyValueStore.java | 2 +
.../kafka/streams/state/QueryableStoreType.java | 4 +
.../kafka/streams/state/QueryableStoreTypes.java | 8 +
.../kafka/streams/state/ReadOnlyKeyValueStore.java | 2 +
.../kafka/streams/state/ReadOnlySessionStore.java | 4 +-
.../kafka/streams/state/ReadOnlyWindowStore.java | 2 +
.../kafka/streams/state/RocksDBConfigSetter.java | 3 +
.../streams/state/SessionBytesStoreSupplier.java | 2 +
.../apache/kafka/streams/state/SessionStore.java | 2 +
.../streams/state/SessionStoreWithHeaders.java | 2 +
.../apache/kafka/streams/state/StateSerdes.java | 2 +
.../apache/kafka/streams/state/StoreBuilder.java | 2 +
.../apache/kafka/streams/state/StoreSupplier.java | 2 +
.../org/apache/kafka/streams/state/Stores.java | 2 +
.../kafka/streams/state/TimestampedBytesStore.java | 3 +
.../streams/state/TimestampedKeyValueStore.java | 4 +
.../state/TimestampedKeyValueStoreWithHeaders.java | 4 +
.../streams/state/TimestampedWindowStore.java | 2 +
.../state/TimestampedWindowStoreWithHeaders.java | 3 +
.../kafka/streams/state/ValueAndTimestamp.java | 2 +
.../kafka/streams/state/ValueTimestampHeaders.java | 2 +
.../kafka/streams/state/VersionedBytesStore.java | 2 +
.../streams/state/VersionedBytesStoreSupplier.java | 2 +
.../streams/state/VersionedKeyValueStore.java | 2 +
.../kafka/streams/state/VersionedRecord.java | 4 +
.../streams/state/VersionedRecordIterator.java | 5 +-
.../streams/state/WindowBytesStoreSupplier.java | 2 +
.../apache/kafka/streams/state/WindowStore.java | 2 +
.../kafka/streams/state/WindowStoreIterator.java | 2 +
.../state/internals/InMemoryWindowStore.java | 2 +-
.../AbstractColumnFamilyAccessorTest.java | 2 +-
.../state/internals/AbstractSegmentsTest.java | 2 +-
.../internals/AbstractSessionBytesStoreTest.java | 2 +-
.../internals/QueryableStoreProviderTest.java | 2 +-
.../streams/state/internals/ThreadCacheTest.java | 2 +-
.../state/internals/WrappingStoreProviderTest.java | 2 +-
.../org/apache/kafka/streams/TestInputTopic.java | 2 +
.../org/apache/kafka/streams/TestOutputTopic.java | 2 +
.../apache/kafka/streams/TopologyTestDriver.java | 2 +
.../kafka/streams/TopologyTestDriverBuilder.java | 3 +
.../processor/api/MockProcessorContext.java | 5 +
.../org/apache/kafka/streams/test/TestRecord.java | 2 +
.../java/org/apache/kafka/tools/ConfigCommand.java | 2 +-
.../kafka/tools/ClientMetricsCommandTest.java | 2 +-
.../java/org/apache/kafka/tools/api/Decoder.java | 4 +
.../org/apache/kafka/tools/api/DefaultDecoder.java | 4 +
.../org/apache/kafka/tools/api/IntegerDecoder.java | 4 +
.../org/apache/kafka/tools/api/LongDecoder.java | 4 +
.../org/apache/kafka/tools/api/RecordReader.java | 3 +
.../org/apache/kafka/tools/api/StringDecoder.java | 4 +
947 files changed, 9820 insertions(+), 44 deletions(-)
diff --git a/api-checker/README.md b/api-checker/README.md
new file mode 100644
index 00000000000..7db9851dde3
--- /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 00000000000..f7955cb6d9b
--- /dev/null
+++ b/api-checker/build.gradle
@@ -0,0 +1,146 @@
+/*
+ * 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')
+
+// Single source of truth for every dependency version used in the api-checker build.
+// Cross-referenced against the root Kafka build's gradle/dependencies.gradle for the deps
+// that exist on both sides (junit, slf4j, mockito); when bumping those in the root catalog,
+// bump them here too. ASM and the Maven plugin family are unique to this included build.
+//
+// ASM versions cap the JDK class-file major 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.
+ext.apiCheckerVersions = [
+ asm: '9.6',
+ slf4j: '2.0.16',
+ junit: '5.9.2',
+ junitPlatform: '1.9.2',
+ mockito: '5.3.1',
+ mavenApi: '3.9.4',
+ mavenPluginAnnotations: '3.9.0',
+]
+
+allprojects {
+ group = 'org.apache.kafka'
+ version = kafkaVersion.get()
+ ext.apiCheckerVersions = rootProject.apiCheckerVersions
+
+ 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']
+ }
+ // Tests legitimately need junit/mockito; main sources don't. Swap the import-control
+ // file for every test-flavoured Checkstyle task so a stray junit/mockito import in a
+ // main file still fails checkstyle.
+ tasks.matching { it.name == 'checkstyleTest' || it.name == 'checkstyleTestFixtures' }.configureEach {
+ configProperties = [importControlFile: 'import-control-api-checker-test.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 : ''
+ }
+ }
+ }
+
+ // Apply Apache-Kafka POM defaults (url, license, developer, scm) to every publication
+ // in this subproject — each MavenPublication block only needs to set its own name,
+ // description, and packaging.
+ publications.withType(MavenPublication).configureEach {
+ pom {
+ 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'
+ }
+ }
+ }
+ }
+
+ 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 00000000000..b1f00b94c1e
--- /dev/null
+++ b/api-checker/core/build.gradle
@@ -0,0 +1,53 @@
+/*
+ * 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.
+ api "org.ow2.asm:asm:${apiCheckerVersions.asm}"
+ api "org.slf4j:slf4j-api:${apiCheckerVersions.slf4j}"
+
+ testImplementation "org.junit.jupiter:junit-jupiter:${apiCheckerVersions.junit}"
+ testImplementation "org.junit.jupiter:junit-jupiter-engine:${apiCheckerVersions.junit}"
+ testImplementation "org.junit.platform:junit-platform-launcher:${apiCheckerVersions.junitPlatform}"
+
+ // 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:${apiCheckerVersions.asm}"
+}
+
+publishing {
+ publications {
+ coreJar(MavenPublication) {
+ artifactId = 'kafka-api-checker-core'
+ from components.java
+ // url, license, developers, scm come from the shared block in ../build.gradle.
+ pom {
+ name = 'Apache Kafka API Checker Core'
+ description = 'Bytecode scanner and reporter shared by the KIP-1265 Gradle and Maven plugins'
+ }
+ }
+ }
+}
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 00000000000..b5d1dab3d7b
--- /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}.
+ *
+ * <p>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<ClassFacts> effectivePublic;
+ /** Classes carrying a direct {@code @InterfaceAudience.Public} — drives the MISSING_JAVADOC iteration. */
+ private final Set<ClassFacts> directPublic;
+ private final Map<String, ClassFacts> byDottedName;
+ private final Map<String, File> 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<ClassFacts> effectivePublic() {
+ return effectivePublic;
+ }
+
+ /** Classes carrying a *direct* {@code @InterfaceAudience.Public}. Drives the MISSING_JAVADOC check. */
+ Set<ClassFacts> 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.
+ *
+ * <p>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<ClassFacts> 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<ClassFacts> effectivePublic = new HashSet<>();
+ private final Set<ClassFacts> directPublic = new HashSet<>();
+ private final Map<String, ClassFacts> byDottedName = new HashMap<>();
+ private final Map<String, File> 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 00000000000..5a991c51a94
--- /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:
+ * <ol>
+ * <li>Read each class's direct bytecode facts (annotations, source-level access).</li>
+ * <li>Walk every class's enclosing-class chain (Hadoop-style inheritance) to resolve effective
+ * audience and assemble the derived dotted-name sets in the surface.</li>
+ * </ol>
+ *
+ * <p>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.
+ *
+ * <p>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<File> projectJars, List<File> referenceJars) throws IOException {
+ ApiSurface.Builder surface = ApiSurface.builder();
+ Map<String, ClassFacts> byBinaryName = new HashMap<>();
+ Set<String> 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<File> projectJars) throws IOException {
+ return scan(projectJars, java.util.Collections.emptyList());
+ }
+
+ private static void scanJars(List<File> jars, Map<String, ClassFacts> byBinaryName,
+ ApiSurface.Builder surface,
+ java.util.function.Consumer<String> markOwned) throws IOException {
+ for (File jar : jars) {
+ try (JarFile jarFile = new JarFile(jar)) {
+ Enumeration<JarEntry> 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<String, ClassFacts> 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 00000000000..40bfd8df7c4
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/CascadeValidator.java
@@ -0,0 +1,455 @@
+/*
+ * 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).
+ *
+ * <p>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<PublicApiViolation> violations = new ArrayList<>();
+ List<PublicApiViolation> 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<File, List<ClassFacts>> 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<File, List<ClassFacts>> 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<PublicApiViolation> violations,
+ List<PublicApiViolation> 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<PublicApiViolation> violations;
+ private final List<PublicApiViolation> 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<PublicApiViolation> headerBuffered = new ArrayList<>();
+
+ CascadeClassVisitor(ClassFacts cls, ApiSurface surface,
+ List<PublicApiViolation> violations,
+ List<PublicApiViolation> 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 <em>name</em>
+ * 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<PublicApiViolation> 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<String, InternalFoo>) live in the
+ // signature, not the erased descriptor — walk them too so the cascade
+ // catches leaks the type-erasure layer would otherwise hide. Position-aware
+ // so a return-type leak doesn't get mislabeled as a parameter.
+ collectMethodSignatureRefs(signature, 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<PublicApiViolation> 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<InternalFoo>` 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<PublicApiViolation> 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<PublicApiViolation> buffered;
+ private String reason;
+
+ BufferedMemberVisitor(List<PublicApiViolation> 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<PublicApiViolation> buffered;
+ private String reason;
+
+ BufferedFieldVisitor(List<PublicApiViolation> 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.
+ *
+ * <p>Used for field signatures and the class-header signature, both of which have a
+ * single cascade label; method signatures go through {@link #collectMethodSignatureRefs}
+ * so the return-type / parameter-type / exception-type positions get distinct labels.
+ */
+ private static void collectSignatureRefs(String signature, String violationType, String message,
+ String owner, String memberName, ApiSurface surface,
+ List<PublicApiViolation> 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);
+ }
+ });
+ }
+
+ /**
+ * Walk a method signature using ASM's position-aware sub-visitor hooks so generic
+ * leaks get labeled by where they actually sit: a leaking return-type
+ * argument becomes {@code INVALID_RETURN_TYPE}, a parameter argument becomes
+ * {@code INVALID_PARAMETER_TYPE}, and a generic throws-clause becomes
+ * {@code INVALID_EXCEPTION_TYPE}. Type-parameter bounds
+ * ({@code <T extends InternalFoo>}) fall under the parameter label since they constrain
+ * caller-supplied types.
+ */
+ private static void collectMethodSignatureRefs(String signature, String owner, String memberName,
+ ApiSurface surface, List<PublicApiViolation> violations) {
+ if (signature == null) return;
+ new SignatureReader(signature).accept(new SignatureVisitor(Opcodes.ASM9) {
+ @Override
+ public SignatureVisitor visitReturnType() {
+ return classTypeCollector("INVALID_RETURN_TYPE",
+ "Public method signature return type exposes non-public API type",
+ owner, memberName, surface, violations);
+ }
+
+ @Override
+ public SignatureVisitor visitParameterType() {
+ return classTypeCollector("INVALID_PARAMETER_TYPE",
+ "Public method signature parameter type exposes non-public API type",
+ owner, memberName, surface, violations);
+ }
+
+ @Override
+ public SignatureVisitor visitExceptionType() {
+ return classTypeCollector("INVALID_EXCEPTION_TYPE",
+ "Public method signature exception type exposes non-public API type",
+ owner, memberName, surface, violations);
+ }
+
+ @Override
+ public SignatureVisitor visitClassBound() {
+ return classTypeCollector("INVALID_PARAMETER_TYPE",
+ "Public method type parameter bound exposes non-public API type",
+ owner, memberName, surface, violations);
+ }
+
+ @Override
+ public SignatureVisitor visitInterfaceBound() {
+ return classTypeCollector("INVALID_PARAMETER_TYPE",
+ "Public method type parameter bound exposes non-public API type",
+ owner, memberName, surface, violations);
+ }
+ });
+ }
+
+ private static SignatureVisitor classTypeCollector(String violationType, String message, String owner,
+ String memberName, ApiSurface surface,
+ List<PublicApiViolation> violations) {
+ return 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<PublicApiViolation> 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.
+ *
+ * <p>The descriptor walk and the generic-signature walk overlap (e.g. {@code Windows} in
+ * {@code extends Windows<TimeWindow>} is seen by both), so dedup before adding to keep
+ * one entry per leaked type per member.
+ */
+ private static void checkBinaryReference(String binaryName, String violationType, String message,
+ String owner, String methodName, ApiSurface surface,
+ List<PublicApiViolation> violations) {
+ if (!binaryName.startsWith("org.apache.kafka.")) return;
+ if (surface.isDeprecated(binaryName)) return;
+ if (surface.isEffectivelyPublic(binaryName)) return;
+ // Dedup on the semantic key (type + violation position + owner + member) rather than
+ // the human-readable description, so the descriptor walk and the signature walk don't
+ // produce two findings for the same leak just because they word the message differently.
+ String descriptionSuffix = ": " + binaryName;
+ for (PublicApiViolation existing : violations) {
+ if (violationType.equals(existing.getViolationType())
+ && owner.equals(existing.getClassName())
+ && java.util.Objects.equals(methodName, existing.getMemberName())
+ && existing.getDescription().endsWith(descriptionSuffix)) {
+ return;
+ }
+ }
+ violations.add(new PublicApiViolation(owner, violationType, message + descriptionSuffix, 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) {
+ boolean noReason = reason.isEmpty();
+ String prettyReason = noReason ? 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(), noReason);
+ }
+
+}
\ 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 00000000000..9625461fb09
--- /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).
+ *
+ * <p>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<PublicApiViolation> violations;
+ private final List<PublicApiViolation> suppressions;
+
+ public CheckResult(List<PublicApiViolation> violations, List<PublicApiViolation> suppressions) {
+ this.violations = violations == null ? List.of() : List.copyOf(violations);
+ this.suppressions = suppressions == null ? List.of() : List.copyOf(suppressions);
+ }
+
+ public List<PublicApiViolation> violations() {
+ return violations;
+ }
+
+ public List<PublicApiViolation> 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 00000000000..2ba45922ce7
--- /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:
+ * <ul>
+ * <li>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.</li>
+ * <li>The set of class-level bytecode annotations the checker recognises (see {@link Flag}).</li>
+ * <li>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.</li>
+ * </ul>
+ *
+ * <p>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.
+ *
+ * <p>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<Flag> 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<Flag> 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 00000000000..38f2b92fa1e
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/JavadocConsistencyValidator.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.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:
+ * <ul>
+ * <li>{@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.</li>
+ * <li>{@code MISSING_PUBLICAPI_ANNOTATION} — a class appears in the javadoc HTML but isn't
+ * effectively {@code @Public} (direct or inherited).</li>
+ * </ul>
+ *
+ * <p>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.
+ *
+ * <p>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<PublicApiViolation> violations = new ArrayList<>();
+
+ Set<String> 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<String> findClassesFromJavadocHtml(File javadocJar) throws IOException {
+ Set<String> classes = new HashSet<>();
+ try (JarFile jar = new JarFile(javadocJar)) {
+ Enumeration<JarEntry> 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;
+ // Skip the auxiliary trees javadoc emits when options.use() / options.linksource() are on:
+ // .../class-use/Foo.html (cross-reference index) and .../src-html/Foo.html (source listing).
+ // Neither is a class page, but both would parse as one and produce a bogus
+ // MISSING_PUBLICAPI_ANNOTATION for "...class-use.Foo" or "...src-html.Foo".
+ if (path.contains("/class-use/") || path.contains("/src-html/")) 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 00000000000..5ca8f919251
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScanner.java
@@ -0,0 +1,624 @@
+/*
+ * 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.
+ *
+ * <h2>Known limitations</h2>
+ * 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:
+ * <ul>
+ * <li><b>Parameter annotations</b> ({@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.</li>
+ * <li><b>Type-use annotations</b> (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.</li>
+ * <li><b>Class literals inside annotation element values</b> — 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.</li>
+ * <li><b>Inlined compile-time constants</b> — 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.</li>
+ * <li><b>Nested types referenced only through a generic type argument</b> — e.g.
+ * {@code Foo<Outer.Inner>} in a signature. The signature visitor handles the outer type
+ * via {@link SignatureVisitor#visitClassType} but doesn't override
+ * {@link SignatureVisitor#visitInnerClassType}, so the inner type is missed. Direct
+ * (non-generic) references like {@code Outer.Inner} as a return / parameter / field
+ * type carry {@code Outer$Inner} in the erased descriptor and ARE caught.</li>
+ * </ul>
+ */
+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<String> 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<String> 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<File> 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<String, PublicApiViolation> violations = new LinkedHashMap<>();
+ Map<String, PublicApiViolation> 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<String, PublicApiViolation> violations,
+ Map<String, PublicApiViolation> 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<String, PublicApiViolation> violations,
+ Map<String, PublicApiViolation> suppressions) throws IOException {
+ try (JarFile jarFile = new JarFile(jar)) {
+ Enumeration<JarEntry> 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<String, PublicApiViolation> violations,
+ Map<String, PublicApiViolation> 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<String, PublicApiViolation> violations,
+ Map<String, PublicApiViolation> 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, consumerClass, binaryName, memberName, location, suppressionReason);
+ } else {
+ recordViolation(violations, key, consumerClass, 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<String, PublicApiViolation> suppressions,
+ String key, String consumerClass, String binaryName,
+ String memberName, String location, String reason) {
+ boolean noReason = reason.isEmpty();
+ String prettyReason = noReason ? 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);
+ // className is the offender (consumer) so the report's `Summary by Class` groups by the
+ // file a developer needs to open — the leaked target lives in the description.
+ suppressions.putIfAbsent(key,
+ new PublicApiViolation(consumerClass, "SUPPRESSED_INTERNAL_API_USAGE", description, memberName, noReason));
+ }
+
+ private static void recordViolation(Map<String, PublicApiViolation> violations,
+ String key, String consumerClass, 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(consumerClass, "INTERNAL_API_USAGE", description, memberName));
+ }
+
+ /**
+ * 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 <em>after</em> 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<String, PublicApiViolation> violations;
+ private final Map<String, PublicApiViolation> suppressions;
+ private String currentClass;
+ private String classSuppression; // null = none; otherwise the reason (may be empty string)
+ private final List<PendingReference> headerRefs = new ArrayList<>();
+
+ ReferenceCollectingClassVisitor(Map<String, PublicApiViolation> violations,
+ Map<String, PublicApiViolation> 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<T> extends Foo<Internal>}) 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 ? "<unknown>" : 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<String, InternalFoo> 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<PendingReference> 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 <em>after</em> 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<PendingReference> 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<PendingReference> 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<PendingReference> 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 <em>owner</em> (the declaring class) and its
+ * <em>type</em> (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 00000000000..eea888f4b55
--- /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:
+ * <ul>
+ * <li>{@link JavadocConsistencyValidator} for {@code MISSING_JAVADOC} / {@code MISSING_PUBLICAPI_ANNOTATION}.</li>
+ * <li>{@link CascadeValidator} for {@code INVALID_RETURN_TYPE} / {@code INVALID_PARAMETER_TYPE} /
+ * {@code INVALID_EXCEPTION_TYPE} with {@code @SuppressKafkaInternalApiUsage} suppression.</li>
+ * <li>{@link PluginDeveloperApiUsageScanner} for the consumer-side check (via {@link #checkBytecode}).</li>
+ * </ul>
+ *
+ * <p>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<File> projectJars, List<File> referenceJars) throws IOException {
+ this.surface = ApiSurfaceScanner.scan(projectJars, referenceJars);
+ }
+
+ /** Convenience for the consumer-side scanner: no separate reference jars needed. */
+ public PublicApiChecker(List<File> 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<PublicApiViolation> violations = new ArrayList<>(javadoc.violations());
+ violations.addAll(cascade.violations());
+ List<PublicApiViolation> 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<File> 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<File> collectExistingRoots(Iterable<File> roots) {
+ List<File> 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.
+ *
+ * <p>Deprecation is intentionally <em>not</em> 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 00000000000..3a55eecb578
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/PublicApiViolation.java
@@ -0,0 +1,109 @@
+/*
+ * 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;
+ private final boolean lacksReason;
+
+ public PublicApiViolation(String className, String violationType, String description, String memberName) {
+ this(className, violationType, description, memberName, false);
+ }
+
+ /**
+ * Suppression-constructor variant: pass {@code lacksReason=true} when the underlying
+ * {@code @SuppressKafkaInternalApiUsage} carried no {@code value()}. KIP-1265 makes the
+ * reason required, so the reporter surfaces unjustified suppressions as a warning.
+ */
+ public PublicApiViolation(String className, String violationType, String description,
+ String memberName, boolean lacksReason) {
+ this.className = className;
+ this.violationType = violationType;
+ this.description = description;
+ this.memberName = memberName;
+ this.lacksReason = lacksReason;
+ }
+
+ 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 represents a suppression whose {@code @SuppressKafkaInternalApiUsage}
+ * annotation carried no {@code value()}. KIP-1265 makes the reason required, so the
+ * checker warns on unjustified suppressions.
+ */
+ public boolean lacksReason() {
+ return lacksReason;
+ }
+
+ @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 00000000000..6d30aa22727
--- /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<String> setter;
+ private boolean assigned;
+
+ ReasonCaptureVisitor(Consumer<String> 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 00000000000..cd68b57c649
--- /dev/null
+++ b/api-checker/core/src/main/java/org/apache/kafka/apicheck/ViolationReporter.java
@@ -0,0 +1,178 @@
+/*
+ * 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<PublicApiViolation> violations,
+ List<PublicApiViolation> suppressions,
+ File reportFile) throws IOException {
+ reportFile.getParentFile().mkdirs();
+ List<PublicApiViolation> 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<String, List<PublicApiViolation>> violationsByType = groupSorted(
+ violations, PublicApiViolation::getViolationType);
+ for (Map.Entry<String, List<PublicApiViolation>> 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<String, List<PublicApiViolation>> violationsByClass = groupSorted(
+ violations, PublicApiViolation::getClassName);
+ for (Map.Entry<String, List<PublicApiViolation>> 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<PublicApiViolation> 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<PublicApiViolation> 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 <K extends Comparable<? super K>> Map<K, List<PublicApiViolation>> groupSorted(
+ List<PublicApiViolation> violations,
+ java.util.function.Function<PublicApiViolation, K> keyFn) {
+ Map<K, List<PublicApiViolation>> 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<PublicApiViolation> violations, File reportFile) throws IOException {
+ writeTextReport(violations, Collections.emptyList(), reportFile);
+ }
+
+ /**
+ * Print to console using {@link #shouldUseColors()} to decide whether to emit ANSI escapes.
+ * Callers in unknown environments (Gradle / Maven tasks that may run under CI / redirected
+ * output) should prefer this overload over hard-coding {@code useColors=true}.
+ */
+ public void printToConsole(List<PublicApiViolation> violations,
+ List<PublicApiViolation> suppressions) {
+ printToConsole(violations, suppressions, shouldUseColors());
+ }
+
+ /**
+ * True when stdout is attached to a real terminal and the user hasn't opted out via the
+ * {@code NO_COLOR} convention (<a href="https://no-color.org/">no-color.org</a>). Returns
+ * false under CI or when output has been redirected, so colored output doesn't leak ANSI
+ * escapes into logs/files.
+ */
+ public static boolean shouldUseColors() {
+ if (System.getenv("NO_COLOR") != null) return false;
+ return System.console() != null;
+ }
+
+ /**
+ * 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<PublicApiViolation> violations,
+ List<PublicApiViolation> 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 00000000000..3e2c8b42f78
--- /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<ClassFacts> 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 00000000000..42155a77c2e
--- /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 00000000000..beda30ad3d3
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/CascadeValidatorTest.java
@@ -0,0 +1,424 @@
+/*
+ * 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 publicMethodWithInternalInGenericReturnSignature_emitsReturnTypeViolation() throws IOException {
+ // The erased descriptor is `()Ljava/util/Map;` — no internal type. The generic signature
+ // `()Ljava/util/Map<Ljava/lang/String;Lorg/apache/kafka/internals/Internal;>;` carries
+ // the internal type as a Map value parameter on the *return* position. The signature
+ // walker is position-aware so it surfaces this as INVALID_RETURN_TYPE rather than
+ // mislabeling it as a parameter type.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("getStuff")
+ .returns("Ljava/util/Map;")
+ .signature("()Ljava/util/Map<Ljava/lang/String;Lorg/apache/kafka/internals/Internal;>;")));
+
+ assertTrue(r.violations().stream().anyMatch(v -> "INVALID_RETURN_TYPE".equals(v.getViolationType())
+ && v.getDescription().contains(INTERNAL_BIN)),
+ "generic return-type argument should surface as INVALID_RETURN_TYPE; got: " + r.violations());
+ }
+
+ @Test
+ void publicMethodWithInternalInGenericParameterSignature_emitsParameterTypeViolation() throws IOException {
+ // Generic on the parameter side: `void take(java.util.List<o.a.k.internals.Internal>)`.
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("take")
+ .returns("V")
+ .param("Ljava/util/List;")
+ .signature("(Ljava/util/List<Lorg/apache/kafka/internals/Internal;>;)V")));
+
+ assertTrue(r.violations().stream().anyMatch(v -> "INVALID_PARAMETER_TYPE".equals(v.getViolationType())
+ && v.getDescription().contains(INTERNAL_BIN)),
+ "generic parameter-type argument should surface as INVALID_PARAMETER_TYPE; got: " + r.violations());
+ }
+
+ @Test
+ void publicFieldWithInternalInGenericSignature_emitsFieldTypeViolation() throws IOException {
+ // The erased descriptor is `Ljava/util/List;`. The signature
+ // `Ljava/util/List<Lorg/apache/kafka/internals/Internal;>;` 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<Lorg/apache/kafka/internals/Internal;>;")));
+
+ 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 descriptorAndSignatureWalks_dedupSameType() throws IOException {
+ // When the type appears in both the erased descriptor AND the generic signature (this
+ // happens for non-generic types and for the outer type in `Outer<...>`), the cascade
+ // mustn't emit two violations for the same (member, type, position).
+ CheckResult r = run(owner()
+ .method(AsmClassFactory.method("doIt")
+ .returns(INTERNAL_DESC)
+ .signature("()L" + AsmClassFactory.toInternal(INTERNAL_BIN) + ";")));
+
+ long internalHits = r.violations().stream()
+ .filter(v -> v.getDescription().contains(INTERNAL_BIN))
+ .count();
+ assertEquals(1, internalHits,
+ "descriptor and signature walks must dedup the same type/position: " + r.violations());
+ }
+
+ @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 00000000000..ebd186d942a
--- /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 00000000000..5171fae3c8e
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PluginDeveloperApiUsageScannerTest.java
@@ -0,0 +1,513 @@
+/*
+ * 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<String> isPublic = name -> "org.apache.kafka.clients.producer.KafkaProducer".equals(name);
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(isPublic);
+ List<PublicApiViolation> 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<PublicApiViolation> 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());
+ // className is the consumer (offender) so the report's `Summary by Class` groups by the
+ // file a developer needs to open; the leaked target lives in the description.
+ assertEquals("com.example.InternalConsumer", v.getClassName());
+ assertTrue(v.getDescription().contains("org.apache.kafka.internals.SecretCabal"),
+ "violation description must name the leaked internal 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<PublicApiViolation> 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<PublicApiViolation> violations = scanner.scan(List.of(jar)).violations();
+
+ assertFalse(violations.isEmpty(), "scan of jar should find the internal reference");
+ assertEquals("com.example.JarConsumer", 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, "<init>", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "com.example.FieldHolder".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<PublicApiViolation> 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<PublicApiViolation> 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<String> audience = name -> "org.apache.kafka.Outer".equals(name);
+ PluginDeveloperApiUsageScanner scanner = new PluginDeveloperApiUsageScanner(audience);
+ List<PublicApiViolation> 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("com.example.NestedConsumer", violations.get(0).getClassName());
+ assertTrue(violations.get(0).getDescription().contains("org.apache.kafka.Outer$Inner"),
+ "description must carry the leaked nested type; got: " + violations.get(0).getDescription());
+ }
+
+ @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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "com.example.CatchConsumer".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<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "com.example.HeaderRef".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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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<PublicApiViolation> violations = scanner.scan(List.of(classFile.getParentFile())).violations();
+
+ assertTrue(violations.stream().anyMatch(v -> "com.example.AbstractConsumer".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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
+ ctor.visitInsn(Opcodes.RETURN);
+ ctor.visitMaxs(0, 0);
+ ctor.visitEnd();
+
+ // public void useIt() { Class<?> c = <referenced>.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<String> 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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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 00000000000..d84a3a00138
--- /dev/null
+++ b/api-checker/core/src/test/java/org/apache/kafka/apicheck/PublicApiCheckerTest.java
@@ -0,0 +1,220 @@
+/*
+ * 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());
+ // className tracks the consumer (offender), so `Summary by Class` in the report groups
+ // by the file a developer has to open. The leaked target lives in the description.
+ assertEquals("com.example.Consumer", result.violations().get(0).getClassName());
+ assertTrue(result.violations().get(0).getDescription().contains("org.apache.kafka.internals.Hidden"),
+ "description must carry the leaked internal type: " + result.violations().get(0).getDescription());
+ }
+
+ @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 00000000000..d1df74baf2d
--- /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<MethodSpec> methods = new ArrayList<>();
+ private final List<FieldSpec> 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<String> paramDescs = new ArrayList<>();
+ private final List<String> 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<Ljava/lang/String;Lorg/apache/kafka/X;>;"}. */
+ 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<Lorg/apache/kafka/X;>;"}. */
+ 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, "<init>", "()V", null, null);
+ mv.visitCode();
+ mv.visitVarInsn(Opcodes.ALOAD, 0);
+ mv.visitMethodInsn(Opcodes.INVOKESPECIAL, superInternal, "<init>", "()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 00000000000..bfa471cb0ff
--- /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<Entry> 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 00000000000..02357121ea5
--- /dev/null
+++ b/api-checker/gradle-plugins/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.
+ */
+
+apply plugin: 'java-gradle-plugin'
+
+dependencies {
+ api project(':core')
+
+ testImplementation "org.junit.jupiter:junit-jupiter:${apiCheckerVersions.junit}"
+ testImplementation "org.junit.jupiter:junit-jupiter-engine:${apiCheckerVersions.junit}"
+ testImplementation "org.junit.platform:junit-platform-launcher:${apiCheckerVersions.junitPlatform}"
+ testImplementation "org.mockito:mockito-core:${apiCheckerVersions.mockito}"
+ 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 {
+ // url, license, developers, scm come from the shared block in ../build.gradle.
+ 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'
+ }
+ }
+ 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'
+ }
+ }
+}
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 00000000000..f17a04c423a
--- /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<Boolean> enabled;
+ private final Property<Boolean> failOnViolation;
+ private final Property<Boolean> 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<Boolean> getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled.set(enabled);
+ }
+
+ public Property<Boolean> getFailOnViolation() {
+ return failOnViolation;
+ }
+
+ public void setFailOnViolation(boolean failOnViolation) {
+ this.failOnViolation.set(failOnViolation);
+ }
+
+ public Property<Boolean> 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 00000000000..4ec5a3b13bd
--- /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.
+ *
+ * <h2>Wiring strategy</h2>
+ *
+ * The plugin uses a reactive {@code Plugins.withType(JavaPlugin.class)} callback rather than
+ * {@code project.afterEvaluate(...)} for the source-set + lifecycle wiring. Two reasons:
+ *
+ * <ul>
+ * <li><b>Order-insensitive</b>. {@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.</li>
+ * <li><b>Configuration-cache friendly</b>. {@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.</li>
+ * </ul>
+ *
+ * <h2>How {@code classDirs} is fed</h2>
+ *
+ * The extension's {@code classDirs} starts empty. When the Java plugin is applied, this plugin
+ * <em>adds</em> {@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.
+ *
+ * <p>The task's {@code classDirs} is wired to the extension's {@code ConfigurableFileCollection}
+ * by reference at task-registration time. Because {@code Property<FileCollection>.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> {
+
+ /** 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<KafkaInternalApiCheckerTask> 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 00000000000..de093447c13
--- /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.api.tasks.CacheableTask;
+
+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.
+ *
+ * <p>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.
+ */
+@CacheableTask
+public class KafkaInternalApiCheckerTask extends DefaultTask {
+
+ private final Property<Boolean> enabled = getProject().getObjects().property(Boolean.class);
+ private final Property<Boolean> failOnViolation = getProject().getObjects().property(Boolean.class);
+ private final Property<Boolean> failOnNoKafkaDependency = getProject().getObjects().property(Boolean.class);
+ private final Property<FileCollection> 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<File> kafkaJars = new ArrayList<>(kafkaDependencyJars.getFiles());
+ if (kafkaJars.isEmpty()) {
+ handleNoKafkaDependency();
+ return;
+ }
+
+ List<File> 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<File> kafkaJars, List<File> 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<PublicApiViolation> violations = result.violations();
+ List<PublicApiViolation> suppressions = result.suppressions();
+
+ ViolationReporter reporter = new ViolationReporter();
+ File report = reportFile.get().getAsFile();
+ reporter.writeTextReport(violations, suppressions, report);
+ reporter.printToConsole(violations, suppressions);
+
+ 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<Boolean> getCheckerEnabled() {
+ return enabled;
+ }
+
+ @Input
+ public Property<Boolean> getFailOnViolation() {
+ return failOnViolation;
+ }
+
+ @Input
+ public Property<Boolean> getFailOnNoKafkaDependency() {
+ return failOnNoKafkaDependency;
+ }
+
+ @InputFiles
+ @PathSensitive(PathSensitivity.RELATIVE)
+ public Property<FileCollection> 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 00000000000..6aca486d028
--- /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<Boolean> enabled;
+ private final Property<Boolean> 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<Boolean> getEnabled() {
+ return enabled;
+ }
+
+ public void setEnabled(boolean enabled) {
+ this.enabled.set(enabled);
+ }
+
+ public Property<Boolean> 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 00000000000..2774b56c262
--- /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> {
+
+ /** 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<KafkaPublicApiCheckerTask> 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<DefaultTask> 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 00000000000..80252aa1a39
--- /dev/null
+++ b/api-checker/gradle-plugins/src/main/java/org/apache/kafka/gradle/KafkaPublicApiCheckerTask.java
@@ -0,0 +1,180 @@
+/*
+ * 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.api.tasks.CacheableTask;
+
+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.
+ *
+ * <p>Cacheable: the inputs (project jars, reference jars, javadoc jar) and the single output
+ * report file are all declared, and the scan is a pure function of those inputs. Applied to
+ * every subproject so unchanged modules become cache hits rather than re-running the bytecode
+ * scan on every {@code docsJar}.
+ */
+@CacheableTask
+public class KafkaPublicApiCheckerTask extends DefaultTask {
+
+ private final Property<Boolean> enabled = getProject().getObjects().property(Boolean.class);
+ private final Property<Boolean> 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<PublicApiViolation> violations = result.violations();
+ List<PublicApiViolation> 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);
+
+ 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<Boolean> getCheckerEnabled() {
+ return enabled;
+ }
+
+ @Input
+ public Property<Boolean> 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 00000000000..b90bb63c4b4
--- /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 00000000000..cc0bf8663f3
--- /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.
+ *
+ * <p>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, "<init>", "()V", null, null);
+ ctor.visitCode();
+ ctor.visitVarInsn(Opcodes.ALOAD, 0);
+ ctor.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object", "<init>", "()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 00000000000..43024fbf1a8
--- /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",
+ "<html><body>Resource class documentation</body></html>");
+ addHtmlEntry(jos, "org/apache/kafka/clients/producer/Producer.html",
+ "<html><body>Producer interface documentation</body></html>");
+
+ // Add structural HTML files
+ addHtmlEntry(jos, "index.html", "<html><body>Index</body></html>");
+ addHtmlEntry(jos, "overview-tree.html", "<html><body>Tree</body></html>");
+ }
+
+ 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 00000000000..172f8674822
--- /dev/null
+++ b/api-checker/maven-plugin/build.gradle
@@ -0,0 +1,65 @@
+/*
+ * 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.
+
+// The Maven dependency versions come from the shared ext.apiCheckerVersions block in
+// ../build.gradle. The plugin.xml descriptor templating below reads the same values, so the
+// runtime dep declarations and the descriptor can never drift.
+dependencies {
+ api project(':core')
+
+ implementation "org.apache.maven:maven-plugin-api:${apiCheckerVersions.mavenApi}"
+ implementation "org.apache.maven:maven-core:${apiCheckerVersions.mavenApi}"
+ implementation "org.apache.maven:maven-artifact:${apiCheckerVersions.mavenApi}"
+ implementation "org.apache.maven.plugin-tools:maven-plugin-annotations:${apiCheckerVersions.mavenPluginAnnotations}"
+
+ testImplementation "org.junit.jupiter:junit-jupiter:${apiCheckerVersions.junit}"
+ testImplementation "org.junit.jupiter:junit-jupiter-engine:${apiCheckerVersions.junit}"
+ testImplementation "org.junit.platform:junit-platform-launcher:${apiCheckerVersions.junitPlatform}"
+}
+
+// Template plugin.xml at packaging time so its <version> 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: apiCheckerVersions.mavenApi,
+ mavenPluginAnnotationsVersion: apiCheckerVersions.mavenPluginAnnotations,
+ ])
+ }
+}
+
+publishing {
+ publications {
+ mavenPlugin(MavenPublication) {
+ artifactId = 'kafka-internal-api-checker-maven-plugin'
+ from components.java
+ // url, license, developers, scm come from the shared block in ../build.gradle.
+ pom {
+ name = 'Apache Kafka Internal API Checker Maven Plugin'
+ description = 'Maven plugin that flags references to non-@Public Kafka classes in a downstream project'
+ packaging = 'maven-plugin'
+ }
+ }
+ }
+}
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 00000000000..b4759956bc6
--- /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.
+ *
+ * <p>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<File> 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<File> kafkaJars = getKafkaJarsFromDependencies();
+ if (kafkaJars.isEmpty()) {
+ handleNoKafkaDependency();
+ return;
+ }
+
+ List<File> 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 <failOnNoKafkaDependency>true</failOnNoKafkaDependency> to make this fatal.");
+ }
+
+ private void runCheck(List<File> kafkaJars, List<File> 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<PublicApiViolation> violations = result.violations();
+ List<PublicApiViolation> suppressions = result.suppressions();
+
+ ViolationReporter reporter = new ViolationReporter();
+ reporter.writeTextReport(violations, suppressions, reportFile);
+ reporter.printToConsole(violations, suppressions);
+
+ 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 <classesDirectories>} explicitly.
+ */
+ private List<File> getDefaultClassesDirectories() {
+ List<File> dirs = new ArrayList<>();
+ File mainClasses = new File(project.getBuild().getOutputDirectory());
+ if (mainClasses.exists()) {
+ dirs.add(mainClasses);
+ }
+ return dirs;
+ }
+
+ private List<File> getKafkaJarsFromDependencies() {
+ List<File> 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<File> 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 00000000000..1850e42eef9
--- /dev/null
+++ b/api-checker/maven-plugin/src/main/resources/META-INF/maven/plugin.xml
@@ -0,0 +1,134 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+ 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.
+-->
+
+<plugin>
+ <name>Apache Kafka Internal API Checker Maven Plugin</name>
+ <description>Maven plugin to check that external projects don't use internal Kafka APIs</description>
+ <groupId>org.apache.kafka</groupId>
+ <artifactId>kafka-internal-api-checker-maven-plugin</artifactId>
+ <version>@version@</version>
+ <goalPrefix>kafka-internal-api-checker</goalPrefix>
+ <isolatedRealm>false</isolatedRealm>
+ <inheritedByDefault>true</inheritedByDefault>
+
+ <mojos>
+ <mojo>
+ <goal>verify</goal>
+ <description>Checks source code for usage of internal Kafka APIs</description>
+ <requiresDirectInvocation>false</requiresDirectInvocation>
+ <requiresProject>true</requiresProject>
+ <requiresReports>false</requiresReports>
+ <aggregator>false</aggregator>
+ <requiresOnline>false</requiresOnline>
+ <inheritedByDefault>true</inheritedByDefault>
+ <phase>verify</phase>
+ <implementation>org.apache.kafka.maven.KafkaInternalApiCheckerMojo</implementation>
+ <language>java</language>
+ <instantiationStrategy>per-lookup</instantiationStrategy>
+ <executionStrategy>once-per-session</executionStrategy>
+ <threadSafe>true</threadSafe>
+ <requiresDependencyResolution>compile+runtime</requiresDependencyResolution>
+
+ <parameters>
+ <parameter>
+ <name>project</name>
+ <type>org.apache.maven.project.MavenProject</type>
+ <required>true</required>
+ <editable>false</editable>
+ <description>The Maven project</description>
+ </parameter>
+
+ <parameter>
+ <name>enabled</name>
+ <type>boolean</type>
+ <required>false</required>
+ <editable>true</editable>
+ <description>Enable/disable the internal API checker</description>
+ </parameter>
+
+ <parameter>
+ <name>failOnViolation</name>
+ <type>boolean</type>
+ <required>false</required>
+ <editable>true</editable>
+ <description>Fail build on violations</description>
+ </parameter>
+
+ <parameter>
+ <name>failOnNoKafkaDependency</name>
+ <type>boolean</type>
+ <required>false</required>
+ <editable>true</editable>
+ <description>Fail the build when no org.apache.kafka:* dependency is found on the classpath (default: false, which warns and skips)</description>
+ </parameter>
+
+ <parameter>
+ <name>classesDirectories</name>
+ <type>java.util.List</type>
+ <required>false</required>
+ <editable>true</editable>
+ <description>Compiled-class directories or jars to scan for internal API usage</description>
+ </parameter>
+
+ <parameter>
+ <name>reportFile</name>
+ <type>java.io.File</type>
+ <required>false</required>
+ <editable>true</editable>
+ <description>Report file location</description>
+ </parameter>
+ </parameters>
+
+ <configuration>
+ <project implementation="org.apache.maven.project.MavenProject" default-value="${project}" />
+ <enabled implementation="boolean" default-value="true">${kafka.internal-api-checker.enabled}</enabled>
+ <failOnViolation implementation="boolean" default-value="true">${kafka.internal-api-checker.failOnViolation}</failOnViolation>
+ <failOnNoKafkaDependency implementation="boolean" default-value="false">${kafka.internal-api-checker.failOnNoKafkaDependency}</failOnNoKafkaDependency>
+ <classesDirectories implementation="java.util.List" />
+ <reportFile implementation="java.io.File" default-value="${project.build.directory}/reports/kafka-internal-api-usage.txt" />
+ </configuration>
+ </mojo>
+ </mojos>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-plugin-api</artifactId>
+ <type>jar</type>
+ <version>@mavenApiVersion@</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-core</artifactId>
+ <type>jar</type>
+ <version>@mavenApiVersion@</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven</groupId>
+ <artifactId>maven-artifact</artifactId>
+ <type>jar</type>
+ <version>@mavenApiVersion@</version>
+ </dependency>
+ <dependency>
+ <groupId>org.apache.maven.plugin-tools</groupId>
+ <artifactId>maven-plugin-annotations</artifactId>
+ <type>jar</type>
+ <version>@mavenPluginAnnotationsVersion@</version>
+ </dependency>
+ </dependencies>
+</plugin>
\ No newline at end of file
diff --git a/api-checker/maven-plugin/src/test/java/org/apache/kafka/maven/PluginXmlParityTest.java b/api-checker/maven-plugin/src/test/java/org/apache/kafka/maven/PluginXmlParityTest.java
new file mode 100644
index 00000000000..b2f6cc3e105
--- /dev/null
+++ b/api-checker/maven-plugin/src/test/java/org/apache/kafka/maven/PluginXmlParityTest.java
@@ -0,0 +1,146 @@
+/*
+ * 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.junit.jupiter.api.Test;
+import org.objectweb.asm.ClassReader;
+import org.objectweb.asm.ClassVisitor;
+import org.objectweb.asm.FieldVisitor;
+import org.objectweb.asm.Opcodes;
+import org.w3c.dom.Document;
+import org.w3c.dom.Element;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+import javax.xml.parsers.DocumentBuilderFactory;
+import java.io.File;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.TreeSet;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Catches drift between the Mojo's {@code @Parameter} fields and the hand-maintained
+ * {@code plugin.xml} descriptor. Adding a parameter to the Mojo without editing the descriptor
+ * silently makes it non-configurable; this test fires the moment the two sides disagree.
+ *
+ * <p>If this fails, update {@code src/main/resources/META-INF/maven/plugin.xml} to add or remove
+ * the listed parameter name(s) so the two stay in lockstep.
+ */
+class PluginXmlParityTest {
+
+ @Test
+ void everyParameterFieldHasAMatchingDescriptorEntry() throws Exception {
+ Set<String> mojoParams = parameterFieldsOf(KafkaInternalApiCheckerMojo.class);
+ Set<String> xmlParams = parametersDeclaredInPluginXml();
+
+ assertEquals(new TreeSet<>(mojoParams), new TreeSet<>(xmlParams),
+ "Mojo @Parameter fields and plugin.xml <parameter> entries are out of sync. "
+ + "Mojo: " + new TreeSet<>(mojoParams) + ", plugin.xml: " + new TreeSet<>(xmlParams)
+ + ". Edit src/main/resources/META-INF/maven/plugin.xml to add/remove entries.");
+
+ // And the <configuration> block must carry an entry for each parameter too, otherwise the
+ // parameter is declared but not wired to its property/default-value.
+ Set<String> xmlConfig = configurationEntriesInPluginXml();
+ assertTrue(xmlConfig.containsAll(mojoParams),
+ "plugin.xml <configuration> is missing entries for: "
+ + diff(mojoParams, xmlConfig));
+ }
+
+ /**
+ * Maven's {@code @Parameter} carries {@code @Retention(CLASS)}, so reflection on the loaded
+ * class returns zero matches. Read the .class file directly via ASM, whose visitor pipeline
+ * surfaces both CLASS- and RUNTIME-retained annotations.
+ */
+ private static Set<String> parameterFieldsOf(Class<?> mojo) throws Exception {
+ Set<String> names = new LinkedHashSet<>();
+ String resourcePath = mojo.getName().replace('.', '/') + ".class";
+ try (InputStream in = mojo.getClassLoader().getResourceAsStream(resourcePath)) {
+ if (in == null) throw new IllegalStateException("Mojo .class not found: " + resourcePath);
+ new ClassReader(in).accept(new ClassVisitor(Opcodes.ASM9) {
+ @Override
+ public FieldVisitor visitField(int access, String name, String descriptor,
+ String signature, Object value) {
+ return new FieldVisitor(Opcodes.ASM9) {
+ @Override
+ public org.objectweb.asm.AnnotationVisitor visitAnnotation(String desc, boolean visible) {
+ if ("Lorg/apache/maven/plugins/annotations/Parameter;".equals(desc)) {
+ names.add(name);
+ }
+ return null;
+ }
+ };
+ }
+ }, ClassReader.SKIP_CODE | ClassReader.SKIP_DEBUG | ClassReader.SKIP_FRAMES);
+ }
+ return names;
+ }
+
+ private static Set<String> parametersDeclaredInPluginXml() throws Exception {
+ Document doc = loadPluginXml();
+ NodeList params = doc.getElementsByTagName("parameter");
+ Set<String> names = new LinkedHashSet<>();
+ for (int i = 0; i < params.getLength(); i++) {
+ Node n = params.item(i);
+ // Skip <parameter> elements that aren't a direct child of <parameters> — e.g. nested
+ // tag names that happen to collide. plugin.xml's only "parameter" nodes are inside
+ // <mojo><parameters>, so this guard is defensive.
+ if (!"parameters".equals(n.getParentNode().getNodeName())) continue;
+ NodeList nameNodes = ((Element) n).getElementsByTagName("name");
+ if (nameNodes.getLength() > 0) {
+ names.add(nameNodes.item(0).getTextContent().trim());
+ }
+ }
+ return names;
+ }
+
+ private static Set<String> configurationEntriesInPluginXml() throws Exception {
+ Document doc = loadPluginXml();
+ NodeList configs = doc.getElementsByTagName("configuration");
+ Set<String> names = new LinkedHashSet<>();
+ for (int i = 0; i < configs.getLength(); i++) {
+ NodeList children = configs.item(i).getChildNodes();
+ for (int j = 0; j < children.getLength(); j++) {
+ Node child = children.item(j);
+ if (child.getNodeType() == Node.ELEMENT_NODE) {
+ names.add(child.getNodeName());
+ }
+ }
+ }
+ return names;
+ }
+
+ private static Document loadPluginXml() throws Exception {
+ File f = new File("src/main/resources/META-INF/maven/plugin.xml");
+ assertTrue(f.exists(), "plugin.xml not found at " + f.getAbsolutePath()
+ + " — run this test from the :maven-plugin project directory");
+ DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
+ // Don't try to fetch the DTD over the network — Maven's plugin descriptor schema isn't
+ // hosted at a URL the test runner can reach in CI.
+ dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
+ return dbf.newDocumentBuilder().parse(f);
+ }
+
+ private static Set<String> diff(Set<String> required, Set<String> have) {
+ Set<String> missing = new TreeSet<>(required);
+ missing.removeAll(have);
+ return missing;
+ }
+}
diff --git a/api-checker/settings.gradle b/api-checker/settings.gradle
new file mode 100644
index 00000000000..8389e1cf9d6
--- /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 5f7a0c4ff70..dfd7ded029c 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-test.xml b/checkstyle/import-control-api-checker-test.xml
new file mode 100644
index 00000000000..ed651abfbde
--- /dev/null
+++ b/checkstyle/import-control-api-checker-test.xml
@@ -0,0 +1,35 @@
+<!DOCTYPE import-control PUBLIC
+ "-//Puppy Crawl//DTD Import Control 1.1//EN"
+ "http://www.puppycrawl.com/dtds/import_control_1_1.dtd">
+<!--
+ 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.
+-->
+
+<!-- Test-scope import policy for the api-checker included build. Same allowlist as the main
+ scope plus the test frameworks (junit, mockito, gradle's testkit). Wired in via the
+ checkstyleTest task's configProperties in api-checker/build.gradle. -->
+<import-control pkg="org.apache.kafka">
+ <allow pkg="java"/>
+ <allow pkg="javax"/>
+ <allow pkg="org.apache.kafka"/>
+ <allow pkg="org.gradle"/>
+ <allow pkg="org.objectweb.asm"/>
+ <allow pkg="org.slf4j"/>
+ <allow pkg="org.apache.maven"/>
+ <allow pkg="org.junit"/>
+ <allow pkg="org.mockito"/>
+ <allow pkg="org.w3c.dom"/>
+</import-control>
diff --git a/checkstyle/import-control-api-checker.xml b/checkstyle/import-control-api-checker.xml
new file mode 100644
index 00000000000..22edeca4b0c
--- /dev/null
+++ b/checkstyle/import-control-api-checker.xml
@@ -0,0 +1,32 @@
+<!DOCTYPE import-control PUBLIC
+ "-//Puppy Crawl//DTD Import Control 1.1//EN"
+ "http://www.puppycrawl.com/dtds/import_control_1_1.dtd">
+<!--
+ 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.
+-->
+
+<!-- Production import policy for the api-checker included build's main sources — Gradle/Maven
+ plugins and the ASM-based scanner only. junit/mockito are intentionally absent: those
+ belong only in the test source sets (see import-control-api-checker-test.xml), so a stray
+ test-dep leaking into a main-source file fails checkstyle here. -->
+<import-control pkg="org.apache.kafka">
+ <allow pkg="java"/>
+ <allow pkg="org.apache.kafka"/>
+ <allow pkg="org.gradle"/>
+ <allow pkg="org.objectweb.asm"/>
+ <allow pkg="org.slf4j"/>
+ <allow pkg="org.apache.maven"/>
+</import-control>
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 26674e1ccc1..577eb8da977 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<AbortTransactionOptions> {
@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 602c4f96443..e1501fc1bd6 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<TopicPartition, KafkaFuture<Void>> 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 6af55978726..ed55dd1709d 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 12effaf4e63..56959619ac1 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<T extends 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 81e889db30d..09013c36876 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 @@ import java.util.Optional;
* If not provided, the cluster id check is skipped.
*/
@InterfaceStability.Stable
+@InterfaceAudience.Public
public class AddRaftVoterOptions extends AbstractOptions<AddRaftVoterOptions> {
private Optional<String> 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 d42204c5e4e..04fe2de20ee 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 @@ import org.apache.kafka.common.annotation.InterfaceStability;
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Stable
+@InterfaceAudience.Public
public class AddRaftVoterResult {
private final KafkaFuture<Void> 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 313c29ca49e..942b71d6433 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.TopicPartitionReplica;
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 @@ import java.util.Set;
* version required.
* <p>
*/
+@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 204579605bc..0c2923bab4c 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 @@ import java.util.Properties;
*
* 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 39086886156..62e72bb6ba0 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 @@ package org.apache.kafka.clients.admin;
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 @@ import static org.apache.kafka.common.config.ConfigDef.ValidString.in;
/**
* 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 027ccd1e9e0..39dac2ac6b6 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<AlterClientQuotasOptions> {
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 4906184b3c9..2de0158e42d 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 @@ import java.util.Map;
/**
* The result of the {@link Admin#alterClientQuotas(Collection, AlterClientQuotasOptions)} call.
*/
+@InterfaceAudience.Public
public class AlterClientQuotasResult {
private final Map<ClientQuotaEntity, KafkaFuture<Void>> 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 789c9f64a93..f3afc4dbbb1 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 @@ import java.util.stream.Collectors;
* new AlterConfigOp(new ConfigEntry(loggerName, "DEBUG"), OpType.SET)
* </pre>
*/
+@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 31812bbfc16..689a787c970 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<AlterConfigsOptions> {
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 cd9279300de..51639d3b23d 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 @@ import java.util.Map;
/**
* The result of the {@link Admin#incrementalAlterConfigs(Map, AlterConfigsOptions)} call.
*/
+@InterfaceAudience.Public
public class AlterConfigsResult {
private final Map<ConfigResource, KafkaFuture<Void>> 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 c2428b6430a..ea3f80bb4a3 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<AlterConsumerGroupOffsetsOptions> {
}
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 8d78a16b574..b20a55c8b1e 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 @@ 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 org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.kafka.common.protocol.Errors;
@@ -28,6 +29,7 @@ import java.util.stream.Collectors;
/**
* The result of the {@link AdminClient#alterConsumerGroupOffsets(String, Map)} call.
*/
+@InterfaceAudience.Public
public class AlterConsumerGroupOffsetsResult {
private final KafkaFuture<Map<TopicPartition, Errors>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
index 74c9f3dcdec..e1659db4195 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsOptions.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 AdminClient#alterPartitionReassignments(Map, AlterPartitionReassignmentsOptions)}
*/
+@InterfaceAudience.Public
public class AlterPartitionReassignmentsOptions extends AbstractOptions<AlterPartitionReassignmentsOptions> {
private boolean allowReplicationFactorChange = true;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java
index f918074af77..8dd1c457a52 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterPartitionReassignmentsResult.java
@@ -19,12 +19,14 @@ 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 AdminClient#alterPartitionReassignments(Map, AlterPartitionReassignmentsOptions)}.
*/
+@InterfaceAudience.Public
public class AlterPartitionReassignmentsResult {
private final Map<TopicPartition, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java
index b6cca48ec8d..447bde43333 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsOptions.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#alterReplicaLogDirs(Map, AlterReplicaLogDirsOptions)}.
*/
+@InterfaceAudience.Public
public class AlterReplicaLogDirsOptions extends AbstractOptions<AlterReplicaLogDirsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java
index 6b6a98e20f6..20f89e8751a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterReplicaLogDirsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.TopicPartitionReplica;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.errors.ClusterAuthorizationException;
import org.apache.kafka.common.errors.InvalidTopicException;
import org.apache.kafka.common.errors.KafkaStorageException;
@@ -35,6 +36,7 @@ import java.util.concurrent.ExecutionException;
* To retrieve the detailed result per specified {@link TopicPartitionReplica}, use {@link #values()}. To retrieve the
* overall result only, use {@link #all()}.
*/
+@InterfaceAudience.Public
public class AlterReplicaLogDirsResult {
private final Map<TopicPartitionReplica, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java
index b032be54060..bf0309921c0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsOptions.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 {@link Admin#alterShareGroupOffsets(String, Map, AlterShareGroupOffsetsOptions)}.
*/
+@InterfaceAudience.Public
public class AlterShareGroupOffsetsOptions extends AbstractOptions<AlterShareGroupOffsetsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java
index 293ad549ea8..f2ebd526be7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterShareGroupOffsetsResult.java
@@ -19,6 +19,7 @@ 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 org.apache.kafka.common.errors.ApiException;
import org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.kafka.common.protocol.Errors;
@@ -30,6 +31,7 @@ import java.util.stream.Collectors;
/**
* The result of the {@link Admin#alterShareGroupOffsets(String, Map, AlterShareGroupOffsetsOptions)} call.
*/
+@InterfaceAudience.Public
public class AlterShareGroupOffsetsResult {
private final KafkaFuture<Map<TopicPartition, ApiException>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java
index 2eceacdf254..f3bc28926b5 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsOptions.java
@@ -14,18 +14,20 @@
* 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 org.apache.kafka.common.annotation.InterfaceStability;
import java.util.Map;
-
/**
* Options for the {@link Admin#alterStreamsGroupOffsets(String, Map, AlterStreamsGroupOffsetsOptions)} call.
* <p>
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Evolving
+@InterfaceAudience.Public
public class AlterStreamsGroupOffsetsOptions extends AbstractOptions<AlterStreamsGroupOffsetsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java
index 11813bf767f..5e1249f9b2f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterStreamsGroupOffsetsResult.java
@@ -18,6 +18,7 @@ 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 org.apache.kafka.common.annotation.InterfaceStability;
import java.util.Map;
@@ -28,6 +29,7 @@ import java.util.Map;
* The API of this class is evolving, see {@link AdminClient} for details.
*/
@InterfaceStability.Evolving
+@InterfaceAudience.Public
public class AlterStreamsGroupOffsetsResult {
private final AlterConsumerGroupOffsetsResult delegate;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java
index 9ead2ee0912..26f5d2f8e80 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsOptions.java
@@ -17,10 +17,13 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.List;
/**
* Options for {@link AdminClient#alterUserScramCredentials(List, AlterUserScramCredentialsOptions)}
*/
+@InterfaceAudience.Public
public class AlterUserScramCredentialsOptions extends AbstractOptions<AlterUserScramCredentialsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java
index d61d3958863..4b140fe0e92 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/AlterUserScramCredentialsResult.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 java.util.Collections;
import java.util.List;
@@ -27,6 +28,7 @@ import java.util.Objects;
/**
* The result of the {@link Admin#alterUserScramCredentials(List)} call.
*/
+@InterfaceAudience.Public
public class AlterUserScramCredentialsResult {
private final Map<String, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java b/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java
index 5a59eb96980..963d9ce8843 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ClassicGroupDescription.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.ClassicGroupState;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.acl.AclOperation;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.List;
@@ -30,6 +31,7 @@ import java.util.stream.Collectors;
/**
* A detailed description of a single classic group in the cluster.
*/
+@InterfaceAudience.Public
public class ClassicGroupDescription {
private final String groupId;
private final String protocol;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/Config.java b/clients/src/main/java/org/apache/kafka/clients/admin/Config.java
index 59f6f12e266..454f553d6e4 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/Config.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/Config.java
@@ -17,6 +17,8 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
@@ -26,6 +28,7 @@ import java.util.Map;
* A configuration object containing the configuration entries for a resource.
* <p>
*/
+@InterfaceAudience.Public
public class Config {
private final Map<String, ConfigEntry> entries = new HashMap<>();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java b/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java
index cde99ffb852..69134f02a82 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ConfigEntry.java
@@ -17,6 +17,8 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -24,6 +26,7 @@ import java.util.Objects;
/**
* A class representing a configuration entry containing name, value and additional metadata.
*/
+@InterfaceAudience.Public
public class ConfigEntry {
private final String name;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java b/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
index fa9c010393e..599ab934ad0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/ConsumerGroupDescription.java
@@ -22,6 +22,7 @@ import org.apache.kafka.common.GroupState;
import org.apache.kafka.common.GroupType;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.acl.AclOperation;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.Collections;
@@ -34,6 +35,7 @@ import java.util.stream.Collectors;
/**
* A detailed description of a single consumer group in the cluster.
*/
+@InterfaceAudience.Public
public class ConsumerGroupDescription {
private final String groupId;
private final boolean isSimpleConsumerGroup;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java
index e9f771ebd9f..911be059de0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsOptions.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#createAcls(Collection)}.
*/
+@InterfaceAudience.Public
public class CreateAclsOptions extends AbstractOptions<CreateAclsOptions> {
/**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java
index de57fbb8911..c6fa0f87eff 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateAclsResult.java
@@ -19,6 +19,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.acl.AclBinding;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.Map;
@@ -26,6 +27,7 @@ import java.util.Map;
/**
* The result of the {@link Admin#createAcls(Collection)} call.
*/
+@InterfaceAudience.Public
public class CreateAclsResult {
private final Map<AclBinding, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java
index f7ef1d4619d..2e5708a72a0 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenOptions.java
@@ -17,6 +17,7 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.security.auth.KafkaPrincipal;
import java.util.LinkedList;
@@ -26,6 +27,7 @@ import java.util.Optional;
/**
* Options for {@link Admin#createDelegationToken(CreateDelegationTokenOptions)}.
*/
+@InterfaceAudience.Public
public class CreateDelegationTokenOptions extends AbstractOptions<CreateDelegationTokenOptions> {
private long maxLifetimeMs = -1;
private List<KafkaPrincipal> renewers = new LinkedList<>();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java
index b8b1a5694f6..76227aabd39 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateDelegationTokenResult.java
@@ -18,11 +18,13 @@
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.security.token.delegation.DelegationToken;
/**
* The result of the {@link KafkaAdminClient#createDelegationToken(CreateDelegationTokenOptions)} call.
*/
+@InterfaceAudience.Public
public class CreateDelegationTokenResult {
private final KafkaFuture<DelegationToken> delegationToken;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java
index ccdbe6d52ca..12a68467434 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsOptions.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#createPartitions(Map)}.
*/
+@InterfaceAudience.Public
public class CreatePartitionsOptions extends AbstractOptions<CreatePartitionsOptions> {
private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java
index 21d26a62467..f60a8ae2bcd 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreatePartitionsResult.java
@@ -18,12 +18,14 @@
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Map;
/**
* The result of the {@link Admin#createPartitions(Map)} call.
*/
+@InterfaceAudience.Public
public class CreatePartitionsResult {
private final Map<String, KafkaFuture<Void>> values;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java
index c81f6e41a90..8b9f11a401f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsOptions.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#createTopics(Collection)}.
*/
+@InterfaceAudience.Public
public class CreateTopicsOptions extends AbstractOptions<CreateTopicsOptions> {
private boolean validateOnly = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java
index 6cf794c65ee..752afc0180b 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/CreateTopicsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.errors.ApiException;
import java.util.Collection;
@@ -27,6 +28,7 @@ import java.util.stream.Collectors;
/**
* The result of {@link Admin#createTopics(Collection)}.
*/
+@InterfaceAudience.Public
public class CreateTopicsResult {
static final int UNKNOWN = -1;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java
index 59d5ce2bb19..dee2ceab703 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsOptions.java
@@ -17,11 +17,14 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Collection;
/**
* Options for the {@link Admin#deleteAcls(Collection)} call.
*/
+@InterfaceAudience.Public
public class DeleteAclsOptions extends AbstractOptions<DeleteAclsOptions> {
/**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java
index db43ac3f047..b0909fb316a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteAclsResult.java
@@ -21,6 +21,7 @@ import org.apache.kafka.common.KafkaException;
import org.apache.kafka.common.KafkaFuture;
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.errors.ApiException;
import java.util.ArrayList;
@@ -31,6 +32,7 @@ import java.util.Map;
/**
* The result of the {@link Admin#deleteAcls(Collection)} call.
*/
+@InterfaceAudience.Public
public class DeleteAclsResult {
/**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java
index 014b9fa9d4f..920367dc5e1 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsOptions.java
@@ -14,13 +14,17 @@
* 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.Set;
/**
* Options for the {@link Admin#deleteConsumerGroupOffsets(String, Set)} call.
*/
+@InterfaceAudience.Public
public class DeleteConsumerGroupOffsetsOptions extends AbstractOptions<DeleteConsumerGroupOffsetsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java
index 80aaeb2a4bc..ffdee4a1108 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupOffsetsResult.java
@@ -18,6 +18,7 @@ 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 org.apache.kafka.common.internals.KafkaFutureImpl;
import org.apache.kafka.common.protocol.Errors;
@@ -27,6 +28,7 @@ import java.util.Set;
/**
* The result of the {@link Admin#deleteConsumerGroupOffsets(String, Set)} call.
*/
+@InterfaceAudience.Public
public class DeleteConsumerGroupOffsetsResult {
private final KafkaFuture<Map<TopicPartition, Errors>> future;
private final Set<TopicPartition> partitions;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java
index b8bc1ecba74..91ba4492b59 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsOptions.java
@@ -14,13 +14,17 @@
* 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.Collection;
/**
* Options for the {@link Admin#deleteConsumerGroups(Collection)} call.
*/
+@InterfaceAudience.Public
public class DeleteConsumerGroupsOptions extends AbstractOptions<DeleteConsumerGroupsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java
index 39f893b047b..355e9732a43 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteConsumerGroupsResult.java
@@ -14,9 +14,11 @@
* 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.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.HashMap;
@@ -26,6 +28,7 @@ import java.util.Map;
* The result of the {@link Admin#deleteConsumerGroups(Collection, DeleteConsumerGroupsOptions)} call for
* {@code Collection<String>} input.
*/
+@InterfaceAudience.Public
public class DeleteConsumerGroupsResult {
private final Map<String, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java
index 320d3d8cb6c..9b844c227b4 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsOptions.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#deleteRecords(Map, DeleteRecordsOptions)}.
*/
+@InterfaceAudience.Public
public class DeleteRecordsOptions extends AbstractOptions<DeleteRecordsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java
index 061403eeb16..586eff2e010 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteRecordsResult.java
@@ -19,12 +19,14 @@ 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 the {@link Admin#deleteRecords(Map)} call.
*/
+@InterfaceAudience.Public
public class DeleteRecordsResult {
private final Map<TopicPartition, KafkaFuture<DeletedRecords>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java
index e38fdf10745..c9497e103d6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsOptions.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.Set;
/**
* Options for {@link Admin#deleteShareGroupOffsets(String, Set, DeleteShareGroupOffsetsOptions)}.
*/
+@InterfaceAudience.Public
public class DeleteShareGroupOffsetsOptions extends AbstractOptions<DeleteShareGroupOffsetsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java
index 65231d1ca0f..76c93aa471b 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupOffsetsResult.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.errors.ApiException;
import org.apache.kafka.common.internals.KafkaFutureImpl;
@@ -26,6 +27,7 @@ import java.util.Set;
/**
* The result of the {@link Admin#deleteShareGroupOffsets(String, Set, DeleteShareGroupOffsetsOptions)} call.
*/
+@InterfaceAudience.Public
public class DeleteShareGroupOffsetsResult {
private final KafkaFuture<Map<String, ApiException>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java
index 1709f169cb1..278ddb248b1 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsOptions.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.Collection;
/**
* Options for {@link Admin#deleteShareGroups(Collection, DeleteShareGroupsOptions)}.
*/
+@InterfaceAudience.Public
public class DeleteShareGroupsOptions extends AbstractOptions<DeleteShareGroupsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java
index c4831e58494..ebd1840cdda 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteShareGroupsResult.java
@@ -14,9 +14,11 @@
* 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.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.HashMap;
@@ -25,6 +27,7 @@ import java.util.Map;
/**
* The result of the {@link Admin#deleteShareGroups(Collection, DeleteShareGroupsOptions)} call.
*/
+@InterfaceAudience.Public
public class DeleteShareGroupsResult {
private final Map<String, KafkaFuture<Void>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java
index 10d39075d8a..1861db4984c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsOptions.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 java.util.Set;
@@ -26,6 +27,7 @@ import java.util.Set;
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Evolving
+@InterfaceAudience.Public
public class DeleteStreamsGroupOffsetsOptions extends AbstractOptions<DeleteStreamsGroupOffsetsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java
index 4966ed063a8..b8930d8c1c7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupOffsetsResult.java
@@ -18,6 +18,7 @@ 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 org.apache.kafka.common.annotation.InterfaceStability;
import org.apache.kafka.common.protocol.Errors;
@@ -30,6 +31,7 @@ import java.util.Set;
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Evolving
+@InterfaceAudience.Public
public class DeleteStreamsGroupOffsetsResult {
private final DeleteConsumerGroupOffsetsResult delegate;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java
index 6ca2ec66a27..7ee61a74919 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsOptions.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 java.util.Collection;
@@ -26,5 +27,6 @@ import java.util.Collection;
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Evolving
+@InterfaceAudience.Public
public class DeleteStreamsGroupsOptions extends AbstractOptions<DeleteStreamsGroupsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java
index 70debbbb519..300f51a8e7d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteStreamsGroupsResult.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.annotation.InterfaceStability;
import java.util.Collection;
@@ -29,6 +30,7 @@ import java.util.Map;
* The API of this class is evolving, see {@link Admin} for details.
*/
@InterfaceStability.Evolving
+@InterfaceAudience.Public
public class DeleteStreamsGroupsResult {
private final DeleteConsumerGroupsResult delegate;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java
index 6f5e9f069e0..3991e68c804 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsOptions.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#deleteTopics(Collection)}.
*/
+@InterfaceAudience.Public
public class DeleteTopicsOptions extends AbstractOptions<DeleteTopicsOptions> {
private boolean retryOnQuotaViolation = true;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java
index eefdbdf9da1..6dfe10c44f7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeleteTopicsResult.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.TopicCollection;
import org.apache.kafka.common.Uuid;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.Map;
@@ -27,6 +28,7 @@ import java.util.Map;
/**
* The result of the {@link Admin#deleteTopics(Collection)} call.
*/
+@InterfaceAudience.Public
public class DeleteTopicsResult {
private final Map<Uuid, KafkaFuture<Void>> topicIdFutures;
private final Map<String, KafkaFuture<Void>> nameFutures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java b/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java
index 97d83a77758..db92cd49674 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DeletedRecords.java
@@ -17,9 +17,12 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
/**
* Represents information about deleted records
*/
+@InterfaceAudience.Public
public class DeletedRecords {
private final long lowWatermark;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java
index f310b6bb73b..a81ed630665 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsOptions.java
@@ -18,10 +18,12 @@
package org.apache.kafka.clients.admin;
import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
/**
* Options for {@link Admin#describeAcls(AclBindingFilter)}.
*/
+@InterfaceAudience.Public
public class DescribeAclsOptions extends AbstractOptions<DescribeAclsOptions> {
/**
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java
index df86515d3ba..d6567a5fe08 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeAclsResult.java
@@ -20,12 +20,14 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.acl.AclBinding;
import org.apache.kafka.common.acl.AclBindingFilter;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Collection;
/**
* The result of the {@link KafkaAdminClient#describeAcls(AclBindingFilter)} call.
*/
+@InterfaceAudience.Public
public class DescribeAclsResult {
private final KafkaFuture<Collection<AclBinding>> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java
index 6422e27524e..95e1335077a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsOptions.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#describeClassicGroups(Collection, DescribeClassicGroupsOptions)}.
*/
+@InterfaceAudience.Public
public class DescribeClassicGroupsOptions extends AbstractOptions<DescribeClassicGroupsOptions> {
private boolean includeAuthorizedOperations;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java
index 22d0134569b..5bfc37d15b6 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClassicGroupsResult.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 java.util.Collection;
import java.util.HashMap;
@@ -27,6 +28,7 @@ import java.util.concurrent.ExecutionException;
/**
* The result of the {@link Admin#describeClassicGroups(Collection, DescribeClassicGroupsOptions)}} call.
*/
+@InterfaceAudience.Public
public class DescribeClassicGroupsResult {
private final Map<String, KafkaFuture<ClassicGroupDescription>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java
index 9030378e222..6f5a11983b3 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasOptions.java
@@ -17,10 +17,12 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.quota.ClientQuotaFilter;
/**
* Options for {@link Admin#describeClientQuotas(ClientQuotaFilter, DescribeClientQuotasOptions)}.
*/
+@InterfaceAudience.Public
public class DescribeClientQuotasOptions extends AbstractOptions<DescribeClientQuotasOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java
index 192918a414f..ff7fc4ff2ac 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClientQuotasResult.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 org.apache.kafka.common.quota.ClientQuotaFilter;
@@ -26,6 +27,7 @@ import java.util.Map;
/**
* The result of the {@link Admin#describeClientQuotas(ClientQuotaFilter, DescribeClientQuotasOptions)} call.
*/
+@InterfaceAudience.Public
public class DescribeClientQuotasResult {
private final KafkaFuture<Map<ClientQuotaEntity, Map<String, Double>>> entities;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java
index 910b64fb428..36a23fd8a2f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterOptions.java
@@ -17,9 +17,12 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
/**
* Options for {@link Admin#describeCluster()}.
*/
+@InterfaceAudience.Public
public class DescribeClusterOptions extends AbstractOptions<DescribeClusterOptions> {
private boolean includeAuthorizedOperations;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java
index 69782d5c9c3..125392c20af 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeClusterResult.java
@@ -20,6 +20,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.Node;
import org.apache.kafka.common.acl.AclOperation;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.Set;
@@ -27,6 +28,7 @@ import java.util.Set;
/**
* The result of the {@link KafkaAdminClient#describeCluster()} call.
*/
+@InterfaceAudience.Public
public class DescribeClusterResult {
private final KafkaFuture<Collection<Node>> nodes;
private final KafkaFuture<Node> controller;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java
index 6586b1ff84d..335cff81d4f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsOptions.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#describeConfigs(Collection)}.
*/
+@InterfaceAudience.Public
public class DescribeConfigsOptions extends AbstractOptions<DescribeConfigsOptions> {
private boolean includeSynonyms = false;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java
index 72cdaf098a3..fbff4d5663c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConfigsResult.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.Collection;
@@ -28,6 +29,7 @@ import java.util.concurrent.ExecutionException;
/**
* The result of the {@link KafkaAdminClient#describeConfigs(Collection)} call.
*/
+@InterfaceAudience.Public
public class DescribeConfigsResult {
private final Map<ConfigResource, KafkaFuture<Config>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java
index 574a8f07fa6..493f851cffb 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsOptions.java
@@ -17,12 +17,15 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
+
import java.util.Collection;
/**
* Options for {@link Admin#describeConsumerGroups(Collection, DescribeConsumerGroupsOptions)}.
* <p>
*/
+@InterfaceAudience.Public
public class DescribeConsumerGroupsOptions extends AbstractOptions<DescribeConsumerGroupsOptions> {
private boolean includeAuthorizedOperations;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java
index f01a6c7b814..29cecb661af 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeConsumerGroupsResult.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 java.util.Collection;
import java.util.HashMap;
@@ -27,6 +28,7 @@ import java.util.concurrent.ExecutionException;
/**
* The result of the {@link KafkaAdminClient#describeConsumerGroups(Collection, DescribeConsumerGroupsOptions)}} call.
*/
+@InterfaceAudience.Public
public class DescribeConsumerGroupsResult {
private final Map<String, KafkaFuture<ConsumerGroupDescription>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java
index 52fad07eae6..5beafc0a2ca 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenOptions.java
@@ -17,6 +17,7 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.security.auth.KafkaPrincipal;
import java.util.List;
@@ -24,6 +25,7 @@ import java.util.List;
/**
* Options for {@link Admin#describeDelegationToken(DescribeDelegationTokenOptions)}.
*/
+@InterfaceAudience.Public
public class DescribeDelegationTokenOptions extends AbstractOptions<DescribeDelegationTokenOptions> {
private List<KafkaPrincipal> owners;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java
index 01355c0385f..37537869e9f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeDelegationTokenResult.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.security.token.delegation.DelegationToken;
import java.util.List;
@@ -25,6 +26,7 @@ import java.util.List;
/**
* The result of the {@link KafkaAdminClient#describeDelegationToken(DescribeDelegationTokenOptions)} call.
*/
+@InterfaceAudience.Public
public class DescribeDelegationTokenResult {
private final KafkaFuture<List<DelegationToken>> delegationTokens;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java
index d364df61dc1..6c33b06884d 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesOptions.java
@@ -14,13 +14,17 @@
* 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.OptionalInt;
/**
* Options for {@link AdminClient#describeFeatures(DescribeFeaturesOptions)}.
*/
+@InterfaceAudience.Public
public class DescribeFeaturesOptions extends AbstractOptions<DescribeFeaturesOptions> {
private OptionalInt nodeId = OptionalInt.empty();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java
index c48dc191430..2b8c3fa521e 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeFeaturesResult.java
@@ -14,15 +14,18 @@
* 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.annotation.InterfaceAudience;
/**
* The result of the {@link Admin#describeFeatures(DescribeFeaturesOptions)} call.
*
* The API of this class is evolving, see {@link Admin} for details.
*/
+@InterfaceAudience.Public
public class DescribeFeaturesResult {
private final KafkaFuture<FeatureMetadata> future;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java
index 41cbf3f6d9c..fd30968394a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsOptions.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#describeLogDirs(Collection)}
*/
+@InterfaceAudience.Public
public class DescribeLogDirsOptions extends AbstractOptions<DescribeLogDirsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java
index cd2e37d7c86..999093027f7 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeLogDirsResult.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 java.util.Collection;
import java.util.HashMap;
@@ -27,6 +28,7 @@ import java.util.concurrent.ExecutionException;
/**
* The result of the {@link Admin#describeLogDirs(Collection)} call.
*/
+@InterfaceAudience.Public
public class DescribeLogDirsResult {
private final Map<Integer, KafkaFuture<Map<String, LogDirDescription>>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java
index 8f54cc81f21..21262905911 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumOptions.java
@@ -14,11 +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.annotation.InterfaceAudience;
+
/**
* Options for {@link Admin#describeMetadataQuorum(DescribeMetadataQuorumOptions)}
*/
+@InterfaceAudience.Public
public class DescribeMetadataQuorumOptions extends AbstractOptions<DescribeMetadataQuorumOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java
index aa9bbe84ead..90209bb500c 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeMetadataQuorumResult.java
@@ -14,13 +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.KafkaFuture;
+import org.apache.kafka.common.annotation.InterfaceAudience;
/**
* The result of {@link Admin#describeMetadataQuorum(DescribeMetadataQuorumOptions)}
*/
+@InterfaceAudience.Public
public class DescribeMetadataQuorumResult {
private final KafkaFuture<QuorumInfo> quorumInfo;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java
index 6ce4680f986..3eff322be28 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersOptions.java
@@ -14,8 +14,11 @@
* 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.Collection;
import java.util.Objects;
import java.util.OptionalInt;
@@ -23,6 +26,7 @@ import java.util.OptionalInt;
/**
* Options for {@link Admin#describeProducers(Collection)}.
*/
+@InterfaceAudience.Public
public class DescribeProducersOptions extends AbstractOptions<DescribeProducersOptions> {
private OptionalInt brokerId = OptionalInt.empty();
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java
index 597c59b8fff..d3e24f8127f 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeProducersResult.java
@@ -14,17 +14,20 @@
* 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.KafkaException;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.TopicPartition;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
+@InterfaceAudience.Public
public class DescribeProducersResult {
private final Map<TopicPartition, KafkaFuture<PartitionProducerState>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java
index 62b665658ad..d54488495bc 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsOptions.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#describeReplicaLogDirs(Collection)}.
*/
+@InterfaceAudience.Public
public class DescribeReplicaLogDirsOptions extends AbstractOptions<DescribeReplicaLogDirsOptions> {
}
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java
index 8e01bf80a91..045238c4747 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeReplicaLogDirsResult.java
@@ -18,6 +18,7 @@ package org.apache.kafka.clients.admin;
import org.apache.kafka.common.KafkaFuture;
import org.apache.kafka.common.TopicPartitionReplica;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.requests.DescribeLogDirsResponse;
import java.util.Collection;
@@ -28,6 +29,7 @@ import java.util.concurrent.ExecutionException;
/**
* The result of {@link Admin#describeReplicaLogDirs(Collection)}.
*/
+@InterfaceAudience.Public
public class DescribeReplicaLogDirsResult {
private final Map<TopicPartitionReplica, KafkaFuture<ReplicaLogDirInfo>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java
index 47a8f008c44..b3571f3e98a 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsOptions.java
@@ -14,13 +14,17 @@
* 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.Collection;
/**
* Options for {@link Admin#describeShareGroups(Collection, DescribeShareGroupsOptions)}.
*/
+@InterfaceAudience.Public
public class DescribeShareGroupsOptions extends AbstractOptions<DescribeShareGroupsOptions> {
private boolean includeAuthorizedOperations;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java
index 9ff0fcc610f..0b2de542ed5 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeShareGroupsResult.java
@@ -14,9 +14,11 @@
* 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.annotation.InterfaceAudience;
import java.util.Collection;
import java.util.HashMap;
@@ -26,6 +28,7 @@ import java.util.concurrent.ExecutionException;
/**
* The result of the {@link KafkaAdminClient#describeShareGroups(Collection, DescribeShareGroupsOptions)}} call.
*/
+@InterfaceAudience.Public
public class DescribeShareGroupsResult {
private final Map<String, KafkaFuture<ShareGroupDescription>> futures;
diff --git a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
index 65f11f8c362..1b75b71bb85 100644
--- a/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
+++ b/clients/src/main/java/org/apache/kafka/clients/admin/DescribeStreamsGroupsOptions.java
@@ -17,6 +17,7 @@
package org.apache.kafka.clients.admin;
+import org.apache.kafka.common.annotation.InterfaceAudience;
import org.apache.kafka.common.annotation.InterfaceStability;
import java.util.Collection;
... 17371 lines suppressed ...