Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import net.ltgt.gradle.errorprone.errorprone

plugins {
id("dd-trace-java.conventions.nullaway")
}

val jspecify = versionCatalogs.named("libs").findLibrary("jspecify").get()
sourceSets.configureEach {
dependencies.add(compileOnlyConfigurationName, jspecify)
}
dependencies {
// Java 8 reflection needs the type annotation classes on the test runtime classpath.
testRuntimeOnly(jspecify)
}

tasks.withType<JavaCompile>().configureEach {
options.errorprone.checkOptions.set(
mapOf(
"NullAway:OnlyNullMarked" to "true",
"NullAway:JSpecifyMode" to "true",
"NullAway:TreatGeneratedAsUnannotated" to "true"
)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class NullAwayConventionsPluginTest : GradleFixture() {
[libraries]
errorprone = { module = "com.google.errorprone:error_prone_core", version = "2.50.0" }
nullaway = { module = "com.uber.nullaway:nullaway", version = "0.14.2" }
jspecify = { module = "org.jspecify:jspecify", version = "1.0.1" }
"""
)
writeRootProject(
Expand Down Expand Up @@ -115,6 +116,93 @@ class NullAwayConventionsPluginTest : GradleFixture() {
assertThat(result.task(":compileJava")?.outcome).describedAs(result.output).isEqualTo(SUCCESS)
}

@Test
fun `JSpecify mode checks nullable type arguments`() {
useJSpecify()
writeJavaSource(
"datadog.example.GenericContract",
"""
package datadog.example;
import java.util.List;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
public class GenericContract {
public List<String> values(List<@Nullable String> values) { return values; }
}
"""
)

val result = run("compileJava", expectFailure = true)

assertThat(result.task(":compileJava")?.outcome).describedAs(result.output).isEqualTo(FAILED)
assertThat(result.output).contains("[NullAway]")
}

@ParameterizedTest
@ValueSource(strings = ["@Nullable String[]", "@Nullable String..."])
fun `JSpecify checks nullable array and varargs elements`(parameterType: String) {
useJSpecify()
writeJavaSource(
"datadog.example.ArrayContract",
"""
package datadog.example;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
public class ArrayContract {
public int length($parameterType values) { return values[0].length(); }
}
"""
)

val result = run("compileJava", expectFailure = true)

assertThat(result.task(":compileJava")?.outcome).describedAs(result.output).isEqualTo(FAILED)
assertThat(result.output).contains("[NullAway]")
}

@Test
fun `JSpecify permits guarded nullable values and leaves unmarked code unchecked`() {
useJSpecify()
writeJavaSource(
"datadog.example.ArrayContract",
"""
package datadog.example;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@NullMarked
public class ArrayContract {
public int length(@Nullable String @Nullable ... values) {
if (values == null) return 0;
int length = 0;
for (String value : values) {
if (value != null) length += value.length();
}
return length;
}
}
"""
)
writeViolation("main_java17")
writeRootProject("""sourceSets.create("main_java17")""", append = true)

val result = run("compileJava", "compileMain_java17Java")

assertThat(result.task(":compileJava")?.outcome).describedAs(result.output).isEqualTo(SUCCESS)
assertThat(result.task(":compileMain_java17Java")?.outcome).describedAs(result.output).isEqualTo(SUCCESS)
}

private fun useJSpecify() {
writeRootProject(
"""
plugins { id("dd-trace-java.conventions.jspecify") }
repositories { mavenCentral() }
tasks.withType<JavaCompile>().configureEach { options.release = 8 }
"""
)
}

private fun writeViolation(sourceSet: String) {
writeJavaSource(
"datadog.example.Violation",
Expand Down
2 changes: 1 addition & 1 deletion components/context/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
plugins {
id("dd-trace-java.module.platform-component")
id("dd-trace-java.conventions.nullaway")
id("dd-trace-java.conventions.jspecify")
}

dependencies {
Expand Down
2 changes: 1 addition & 1 deletion components/context/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt
org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt
org.jacoco:org.jacoco.core:0.8.15=jacocoAnt
org.jacoco:org.jacoco.report:0.8.15=jacocoAnt
org.jspecify:jspecify:1.0.1=annotationProcessor,errorprone,testAnnotationProcessor
org.jspecify:jspecify:1.0.1=annotationProcessor,compileClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import static datadog.context.ContextProviders.binder;
import static datadog.context.ContextProviders.manager;

import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/**
* Immutable context scoped to an execution unit or carrier object.
Expand Down Expand Up @@ -117,8 +117,7 @@ default ContextContinuation capture() {
* @param key the key used to store the value.
* @return the value stored under the key; {@code null} if there is none.
*/
@Nullable
<T> T get(ContextKey<T> key);
@Nullable <T> T get(ContextKey<T> key);

/**
* Creates a copy of this context with the given key-value set.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import java.util.ArrayList;
import java.util.List;
import java.util.function.BinaryOperator;
import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/**
* Static helpers to manipulate context collections.
Expand Down Expand Up @@ -126,7 +126,7 @@ private static Context combineKeepingFirst(Context current, Context next) {
return current;
}
// Always store next value otherwise
Object[] store =
@Nullable Object[] store =
copyOfRange(
currentIndexed.store, 0, max(currentIndexed.store.length, nextSingleton.index + 1));
store[nextSingleton.index] = nextSingleton.value;
Expand All @@ -137,7 +137,7 @@ private static Context combineKeepingFirst(Context current, Context next) {
// * nextIndexed has more values that currentIndexed,
// so the additional values will always be kept
// * nextIndexed has values that currentIndexed do not have
Object[] store = null;
@Nullable Object[] store = null;
// Allocate store if nextIndexed has more elements than currentIndexed
if (nextIndexed.store.length > currentIndexed.store.length) {
store = copyOfRange(currentIndexed.store, 0, nextIndexed.store.length);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package datadog.context;

import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/** Provides {@link ContextManager} and {@link ContextBinder} implementations. */
final class ContextProviders {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import static java.util.Objects.requireNonNull;

import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/** {@link Context} containing no values. */
final class EmptyContext implements SelfScopedContext {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@
import static java.util.Objects.requireNonNull;

import java.util.Arrays;
import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/** {@link Context} containing many values. */
final class IndexedContext implements SelfScopedContext {
final Object[] store;
final @Nullable Object[] store;

IndexedContext(Object[] store) {
IndexedContext(@Nullable Object[] store) {
this.store = store;
}

Expand All @@ -31,7 +31,7 @@ public <T> Context with(ContextKey<T> key, @Nullable T value) {
if (index < this.store.length && this.store[index] == value) {
return this;
}
Object[] newStore = copyOfRange(this.store, 0, max(this.store.length, index + 1));
@Nullable Object[] newStore = copyOfRange(this.store, 0, max(this.store.length, index + 1));
newStore[index] = value;
return new IndexedContext(newStore);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import static java.util.Objects.requireNonNull;

import java.util.Objects;
import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/** {@link Context} containing a single value. */
final class SingletonContext implements SelfScopedContext {
Expand Down Expand Up @@ -37,7 +37,7 @@ public <V> Context with(ContextKey<V> secondKey, @Nullable V secondValue) {
return this;
}
} else {
Object[] store = new Object[max(this.index, secondIndex) + 1];
@Nullable Object[] store = new Object[max(this.index, secondIndex) + 1];
store[this.index] = this.value;
store[secondIndex] = secondValue;
return new IndexedContext(store);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import java.util.Arrays;
import java.util.concurrent.atomic.AtomicIntegerFieldUpdater;
import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

/** {@link ContextManager} that uses a {@link ThreadLocal} to track context per thread. */
final class ThreadLocalContextManager implements ContextManager {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@ParametersAreNonnullByDefault
@NullMarked
package datadog.context;

import javax.annotation.ParametersAreNonnullByDefault;
import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@ParametersAreNonnullByDefault
@NullMarked
package datadog.context.propagation;

import javax.annotation.ParametersAreNonnullByDefault;
import org.jspecify.annotations.NullMarked;
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package datadog.context;

import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;

import java.lang.reflect.AnnotatedArrayType;
import java.lang.reflect.Method;
import org.jspecify.annotations.Nullable;
import org.junit.jupiter.api.Test;

class ContextNullnessTest {
@Test
void typeAnnotationsCanBeReadAtRuntime() throws ReflectiveOperationException {
for (Method method : Context.class.getDeclaredMethods()) {
method.getAnnotatedReturnType();
method.getAnnotatedParameterTypes();
}
assertNotNull(
Context.class
.getMethod("get", ContextKey.class)
.getAnnotatedReturnType()
.getAnnotation(Nullable.class));
AnnotatedArrayType store =
(AnnotatedArrayType) IndexedContext.class.getDeclaredField("store").getAnnotatedType();
assertNull(store.getAnnotation(Nullable.class));
assertNotNull(store.getAnnotatedGenericComponentType().getAnnotation(Nullable.class));
}
}
3 changes: 3 additions & 0 deletions dd-java-agent/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ def generalShadowJarConfig(ShadowJar shadowJarTask) {

// Prevents conflict with other SLF4J instances. Important for premain.
relocate 'org.slf4j', 'datadog.slf4j'
relocate 'org.jspecify.annotations', 'datadog.trace.bootstrap.jspecify.annotations'
// Prevent conflicts with flat class-path when using GraalVM native-images
relocate 'org.jctools', 'datadog.jctools'
relocate 'net.jpountz', 'datadog.jpountz'
Expand Down Expand Up @@ -418,6 +419,8 @@ dependencies {

// Includes for the top level shadow jar
shadowInclude project(path: ':components:environment', configuration: 'shadow')
// Java 8 reflection needs the relocated type annotations on the bootstrap classpath.
shadowInclude libs.jspecify
shadowInclude project(path: ':dd-java-agent:agent-bootstrap')
shadowInclude project(path: ':dd-java-agent:agent-debugger:debugger-bootstrap')
shadowInclude project(path: ':dd-java-agent:agent-otel:otel-bootstrap', configuration: 'shadow')
Expand Down
1 change: 1 addition & 0 deletions dd-java-agent/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
org.jctools:jctools-core-jdk11:4.0.6=sharedShadowInclude,testRuntimeClasspath,traceShadowInclude
org.jctools:jctools-core:4.0.6=sharedShadowInclude,testRuntimeClasspath,traceShadowInclude
org.jspecify:jspecify:1.0.0=testCompileClasspath,testRuntimeClasspath
org.jspecify:jspecify:1.0.1=shadowInclude
org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath
Expand Down
20 changes: 20 additions & 0 deletions docs/how_to_work_with_gradle.md
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,26 @@ The initial adopters outside IAST are `components/context`, `utils/time-utils`,
`utils/version-utils`. Other modules can adopt the convention as their nullness contracts
are checked, keeping source repairs separate from the shared configuration.

For JSpecify contracts, apply `dd-trace-java.conventions.jspecify` instead. It enables
NullAway's `JSpecifyMode` and checks code covered by `@NullMarked`. Add `@NullMarked` to
each production package's `package-info.java`; it does not cover subpackages. Context,
time, and version utilities use this convention. IAST keeps its existing annotations.

The convention adds JSpecify as a compile-only dependency so all source sets can read
production contracts, and to the test runtime for Java 8 reflection. The agent bundles
the annotations under `datadog.trace.bootstrap.jspecify.annotations` on its bootstrap
classpath for Java 8 type reflection. Application `org.jspecify` annotations remain
independent. Published libraries adopting these contracts should expose JSpecify through
their `api` dependencies.

Place `@Nullable` on the intended type: `@Nullable Object[]` permits null elements, while
`Object @Nullable []` permits a null array. Preserve JetBrains `@Contract`, SpotBugs
suppression annotations, concurrency annotations, and Forbidden APIs annotations. They
describe contracts beyond JSpecify nullness. Keep their dependencies while consumers remain.

Compiling `@NullMarked` with `--release 8` emits the known `ElementType.MODULE` warning;
see the [JSpecify compatibility notes](https://github.com/jspecify/jspecify/wiki/version-compatibility).

### Dependencies on Main Projects

Build tooling remains independent of production code. The tasks it registers can consume
Expand Down
2 changes: 2 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ errorprone-gradle-plugin = "3.1.0"
forbiddenapis = "3.11"
gradle-tooling-api = "8.14.6"
jsr305 = "3.0.2"
jspecify = "1.0.1"
nullaway = "0.14.2"
shadow = "9.4.2"
spotbugs-gradle-plugin = "6.5.11"
Expand Down Expand Up @@ -99,6 +100,7 @@ errorprone-gradle-plugin = { module = "net.ltgt.gradle:gradle-errorprone-plugin"
forbiddenapis = { module = "de.thetaphi:forbiddenapis", version.ref = "forbiddenapis" }
gradle-tooling-api = { module = "org.gradle:gradle-tooling-api", version.ref = "gradle-tooling-api" }
jsr305 = { module = "com.google.code.findbugs:jsr305", version.ref = "jsr305" }
jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" }
spotbugs-annotations = { module = "com.github.spotbugs:spotbugs-annotations", version.ref = "spotbugs_annotations" }
spotbugs-gradle-plugin = { module = "com.github.spotbugs.snom:spotbugs-gradle-plugin", version.ref = "spotbugs-gradle-plugin" }
Expand Down
2 changes: 1 addition & 1 deletion utils/time-utils/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
plugins {
`java-library`
id("dd-trace-java.module.internal-library")
id("dd-trace-java.conventions.nullaway")
id("dd-trace-java.conventions.jspecify")
}

val excludedClassesCoverage by extra(
Expand Down
2 changes: 1 addition & 1 deletion utils/time-utils/gradle.lockfile
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ org.jacoco:org.jacoco.agent:0.8.15=jacocoAgent,jacocoAnt
org.jacoco:org.jacoco.ant:0.8.15=jacocoAnt
org.jacoco:org.jacoco.core:0.8.15=jacocoAnt
org.jacoco:org.jacoco.report:0.8.15=jacocoAnt
org.jspecify:jspecify:1.0.1=annotationProcessor,errorprone,testAnnotationProcessor
org.jspecify:jspecify:1.0.1=annotationProcessor,compileClasspath,errorprone,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath
org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath
org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.annotation.Nullable;
import org.jspecify.annotations.Nullable;

public abstract class TimeUtils {

Expand Down
Loading
Loading