Skip to content
Merged
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
35 changes: 35 additions & 0 deletions .agents/skills/jspecify-nullness/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
name: jspecify-nullness
description: JSpecify nullness annotations for Java APIs and tooling. Use when adopting or migrating JSpecify annotations, designing null-safe Java signatures and generics, or interpreting tool conformance and Kotlin interop.
---

# JSpecify Nullness (Java)

## Quick start
- Use `@NullMarked` to make unannotated types non-null by default.
- Add `@Nullable` only where null is allowed; use `@NonNull` sparingly when you must override a nullable type variable use.
- Decide type parameter bounds up front: `<T extends @Nullable Object>` allows nullable type arguments, `<T>` does not.
- Use `@NullUnmarked` to opt out of `@NullMarked` in legacy or incremental areas.
- Keep annotations in recognized type-use positions (arrays, nested types, type arguments).
- Read `references/jspecify-nullness.md` for migration steps, syntax pitfalls, and tooling details.

## Workflow
1. Confirm tool support and constraints (nullness checker, Kotlin version, annotation processors).
2. Add the `org.jspecify:jspecify` dependency and expose it to consumers.
3. Annotate nullable types first, then add `@NullMarked` at class or package scope.
4. Fix generics: set bounds for type parameters and annotate type-variable uses as needed.
5. Run nullness analysis, resolve findings, and repeat for adjacent code.

## Rules of thumb
- Treat unannotated types outside `@NullMarked` as unspecified nullness, not non-null.
- Do not annotate local-variable root types; annotate only type arguments or array components there.
- For arrays, `@Nullable String[]` means nullable elements, while `String @Nullable []` means a nullable array.
- For nested types, use `Map.@Nullable Entry` to mark the nested type, not the outer type.
- Use `@Nullable` on a type variable usage only when null is allowed even if the type argument is non-null.

## Output expectations
- Provide annotated signatures and call-site implications.
- Call out tool-conformance limits rather than promising specific diagnostics.

## References
- Load `references/jspecify-nullness.md` for full guidance and examples.
160 changes: 160 additions & 0 deletions .agents/skills/jspecify-nullness/references/jspecify-nullness.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# JSpecify Nullness - Reference

Concise guidance for applying JSpecify annotations in Java, including scope defaults, generic type parameters, and tooling constraints.

Sources:
- https://jspecify.dev/docs/start-here/
- https://jspecify.dev/docs/user-guide/
- https://jspecify.dev/docs/whether/
- https://jspecify.dev/docs/using/
- https://jspecify.dev/docs/applying/
- https://jspecify.dev/docs/spec/
- https://jspecify.dev/docs/tool-conformance/


## Table of Contents
- [Goals and model](#goals-and-model)
- [Core annotations](#core-annotations)
- [Null-marked scopes](#null-marked-scopes)
- [Type-use placement rules](#type-use-placement-rules)
- [Generics and type parameters](#generics-and-type-parameters)
- [Adoption and migration](#adoption-and-migration)
- [Tooling and conformance](#tooling-and-conformance)
- [Kotlin and annotation processors](#kotlin-and-annotation-processors)
- [Checklist](#checklist)
- [Test prompts](#test-prompts)

## Goals and model
- JSpecify defines standard nullness annotations and precise semantics for Java type usages.
- The model has four nullness states: nullable, non-null, parametric (type variable), and unspecified.
- `@NullMarked` makes unannotated types non-null by default within the scope; outside it, unannotated types are unspecified.

## Core annotations
- `@Nullable` means the annotated type usage can include `null`.
- `@NonNull` means the annotated type usage excludes `null`.
- `@NullMarked` applies to modules, packages, classes, or methods and makes unannotated types non-null by default.
- `@NullUnmarked` cancels a surrounding `@NullMarked` for incremental adoption.

Example:

```java
@NullMarked
final class Strings {
static @Nullable String emptyToNull(String x) {
return x.isEmpty() ? null : x;
}

static String nullToEmpty(@Nullable String x) {
return x == null ? "" : x;
}
}
```

## Null-marked scopes
- Packages are not hierarchical. `@NullMarked` on `com.example` does not affect `com.example.sub`.
- In a null-marked scope, unannotated types are treated as non-null, but local-variable root types are not annotated.
- `@NullUnmarked` is useful when only part of a package or class is ready for annotations.

## Type-use placement rules
JSpecify annotations are type-use annotations, so placement matters:

- Nested types: mark the nested type, not the outer type.

```java
Map.@Nullable Entry<String, String> entry;
```

- Arrays: the token after the annotation is what can be null.

```java
@Nullable String[] nullableElements;
String @Nullable [] nullableArray;
@Nullable String @Nullable [] bothNullable;
```

- Local variables: do not annotate the root type, but do annotate type arguments or array components.

```java
List<@Nullable String> names = new ArrayList<>();
```

If a tool reports an unrecognized annotation location, move the annotation to the nearest simple type or component.

## Generics and type parameters
- A type parameter without an explicit bound behaves like `<T extends Object>`.
- Inside `@NullMarked`, that means the type argument is non-null unless you make the bound nullable.

Allow nullable type arguments:

```java
@NullMarked
interface Box<T extends @Nullable Object> {
T get();
void set(T value);
}
```

Disallow nullable type arguments:

```java
@NullMarked
interface ImmutableBox<T> {
T get();
}
```

Use `@Nullable` on a type-variable usage only when the result can be null even when the type argument is non-null:

```java
@NullMarked
interface ListLike<E extends @Nullable Object> {
@Nullable E firstOrNull();
E get(int index);
}
```

Use `@NonNull` on a type-variable usage to force non-null even when the type argument is nullable:

```java
@NullMarked
interface OptionalLike<T extends @Nullable Object> {
Optional<@NonNull T> toOptional();
}
```

## Adoption and migration
Recommended steps for a new codebase or incremental migration:

1. Add the dependency `org.jspecify:jspecify:1.0.0` and do not hide it from consumers.
2. Start with a small, low-dependency package or class.
3. Mark obvious nullable usages (`return null`, `if (x == null)`, nullable fields).
4. Add `@NullMarked` to the scope and use `@NullUnmarked` for unannotated areas.
5. Fix type parameters and usages in generics.
6. Run nullness analysis and address findings, then expand to calling code.

Migration from JSR-305 or other annotations:
- Update imports to JSpecify.
- Fix array and nested-type placements to comply with type-use rules.
- Expect some build errors due to type-use placement restrictions.

## Tooling and conformance
- JSpecify defines semantics, not required diagnostics. Tools may choose their own error or warning behavior.
- Conformance is described in terms of questions about whether a usage is recognized, what the augmented type is, and whether an expression is a nullness subtype of its context.
- Some tools are partially conformant, especially around generics or unspecified nullness.

## Kotlin and annotation processors
- Kotlin compilers recognize JSpecify annotations; support level varies by version for `@Nullable`, `@NonNull`, and `@NullUnmarked`.
- If you rely on annotation processors like Dagger, you may need JDK 22+ due to older `javac` type-use annotation bugs.

## Checklist
- Use `@NullMarked` at package or class scope.
- Mark nullable return types and parameters with `@Nullable`.
- Decide per type parameter whether nullable type arguments are allowed.
- Verify type-use placement for arrays and nested types.
- Run a JSpecify-aware nullness checker and resolve findings.

## Test prompts
- "Annotate this API with JSpecify, including generics and `@NullMarked`, and explain call-site impacts."
- "Migrate these JSR-305 annotations to JSpecify and fix any type-use placement issues."
- "Given this signature, decide if the type parameter bound should be nullable and justify it."
- "Explain why `Map.@Nullable Entry` is required here and how arrays should be annotated."
1 change: 1 addition & 0 deletions .claude/skills/jspecify-nullness
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ project/local-plugins.sbt
.sbt-scripted/
local.sbt

# OpenCode
.sisyphus

# Bloop
.bsp

Expand Down
3 changes: 2 additions & 1 deletion buildSrc/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ fun version(k: String) =
dependencies {
implementation(libs.gradle.versions.plugin)
implementation(libs.vanniktech.publish.plugin)
// removed errorprone plugin
implementation(libs.errorprone.gradle.plugin)
implementation(libs.nullaway.gradle.plugin)
// Provide plugins used by precompiled script plugins so their ids are available
implementation(libs.kotlin.gradle.plugin)
implementation(libs.kover.gradle.plugin)
Expand Down
8 changes: 8 additions & 0 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@
arrow = "2.2.1.1"
binary-compatibility-validator = "0.18.1"
dokka = "2.1.0"
errorprone = "2.42.0"
errorprone-plugin = "5.1.0"
jetbrains-annotations = "26.0.2"
jspecify = "1.0.0"
kotlin = "2.3.0"
kover = "0.9.5"
nullaway = "0.13.1"
nullaway-plugin = "3.0.0"
publish-plugin = "0.36.0"
versions-plugin = "0.53.0"
kotlinx-coroutines = "1.10.2"
Expand All @@ -14,14 +18,18 @@ kotlinx-coroutines = "1.10.2"
# Plugins specified in buildSrc/build.gradle.kts
binary-compatibility-validator-plugin = { module = "org.jetbrains.kotlinx.binary-compatibility-validator:org.jetbrains.kotlinx.binary-compatibility-validator.gradle.plugin", version.ref = "binary-compatibility-validator" }
dokka-gradle-plugin = { module = "org.jetbrains.dokka:dokka-gradle-plugin", version.ref = "dokka" }
errorprone-gradle-plugin = { module = "net.ltgt.errorprone:net.ltgt.errorprone.gradle.plugin", version.ref = "errorprone-plugin" }
gradle-versions-plugin = { module = "com.github.ben-manes:gradle-versions-plugin", version.ref = "versions-plugin" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kover-gradle-plugin = { module = "org.jetbrains.kotlinx:kover-gradle-plugin", version.ref = "kover" }
nullaway-gradle-plugin = { module = "net.ltgt.nullaway:net.ltgt.nullaway.gradle.plugin", version.ref = "nullaway-plugin" }
vanniktech-publish-plugin = { module = "com.vanniktech:gradle-maven-publish-plugin", version.ref = "publish-plugin" }

# Actual libraries
errorprone-core = { module = "com.google.errorprone:error_prone_core", version.ref = "errorprone" }
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "jetbrains-annotations" }
jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" }
nullaway = { module = "com.uber.nullaway:nullaway", version.ref = "nullaway" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
Expand Down
11 changes: 11 additions & 0 deletions skills-lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"version": 1,
"skills": {
"jspecify-nullness": {
"source": "alexandru/skills",
"sourceType": "github",
"skillPath": "skills/jspecify-nullness/SKILL.md",
"computedHash": "7cd2c85c83bdec95a36ea222db3dce9afc83f8b82b0c3e05602982e364095b46"
}
}
}
29 changes: 28 additions & 1 deletion tasks-jvm/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import net.ltgt.gradle.errorprone.CheckSeverity
import net.ltgt.gradle.errorprone.errorprone
import net.ltgt.gradle.nullaway.nullaway

plugins {
id("tasks.java-project")
id("tasks.versions")
id("net.ltgt.errorprone")
id("net.ltgt.nullaway")
}

mavenPublishing {
Expand All @@ -14,6 +20,8 @@ dependencies {
api(libs.jspecify)

compileOnly(libs.jetbrains.annotations)
errorprone(libs.errorprone.core)
errorprone(libs.nullaway)

testImplementation(platform("org.junit:junit-bom:6.0.2"))
testImplementation("org.junit.jupiter:junit-jupiter")
Expand All @@ -36,7 +44,26 @@ tasks.withType<JavaCompile> {
"-Xlint:deprecation",
// "-Werror"
))

}

tasks.named<JavaCompile>("compileJava") {
options.errorprone {
disableAllChecks = true
check("NullAway", CheckSeverity.ERROR)
nullaway {
error()
onlyNullMarked = true
}
}
}

tasks.named<JavaCompile>("compileTestJava") {
options.errorprone {
disableAllChecks = true
nullaway {
disable()
}
}
}

tasks.register<Test>("testsOn21") {
Expand Down
9 changes: 4 additions & 5 deletions tasks-jvm/src/main/java/org/funfix/tasks/jvm/Cancellable.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NonBlocking;
import org.jetbrains.annotations.Nullable;
import org.jspecify.annotations.Nullable;

import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
Expand Down Expand Up @@ -86,7 +86,6 @@ final class MutableCancellable implements Cancellable {

@Override
public void cancel() {
@Nullable
var state = ref.getAndSet(State.Closed.INSTANCE);
while (state instanceof State.Active active) {
try {
Expand Down Expand Up @@ -151,16 +150,16 @@ private void unregister(final long order) {
while (true) {
final var current = ref.get();
if (current instanceof State.Active active) {
@Nullable var cursor = active;
@Nullable State.Active acc = null;
State.@Nullable Active cursor = active;
State.@Nullable Active acc = null;
while (cursor != null) {
if (cursor.order != order) {
acc = new State.Active(cursor.token, cursor.order, acc);
}
cursor = cursor.rest;
}
// Reversing
@Nullable State.Active update = null;
State.@Nullable Active update = null;
while (acc != null) {
update = new State.Active(acc.token, acc.order, update);
acc = acc.rest;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public record CancellableFuture<T extends @Nullable Object>(
CompletableFuture<? extends T> future,
Cancellable cancellable
) {
public <U> CancellableFuture<U> transform(
public <U extends @Nullable Object> CancellableFuture<U> transform(
Function<? super CompletableFuture<? extends T>, ? extends CompletableFuture<? extends U>> fn
) {
return new CancellableFuture<>(fn.apply(future), cancellable);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package org.funfix.tasks.jvm;

import org.jspecify.annotations.Nullable;

import java.util.function.Function;

/**
Expand All @@ -15,10 +17,11 @@ public interface CloseableFun extends AutoCloseable {
* Converts this blocking finalizer into an asynchronous one
* that can be used for initializing {@link Resource.Acquired}.
*/
default Function<ExitCase, Task<Void>> toAsync() {
default Function<ExitCase, Task<@Nullable Void>> toAsync() {
// NullAway does not propagate the @Nullable Void type witness into the lambda body.
@SuppressWarnings("NullAway")
final Function<ExitCase, Task<Void>> r =
exitCase -> TaskUtils.taskUninterruptibleBlockingIO(() -> {
final Function<ExitCase, Task<@Nullable Void>> r =
exitCase -> TaskUtils.<@Nullable Void>taskUninterruptibleBlockingIO(() -> {
close(exitCase);
return null;
});
Expand Down
Loading
Loading