Gradle plugin applying JSpecify's @NullMarked convention to Java projects.
The main goal of this plugin is to stop writing package-info.java files just to apply @NullMarked annotation to it.
Let the plugin generate one automatically for all your packages.
Applying io.github.malczuuu.nullmarked to a Java project:
- Will generate
package-info.java, for every non-empty package that doesn't have one. - Adds the JSpecify dependency as
compileOnly, unless the build script already declares it there itself. - Registers generated directory as a source directory, so
compileJavapicks it up automatically. Hand-writtenpackage-info.javafiles always win. - Verifies that
package-info.javafiles are present for each package (generated or hand-written) and if nullness annotations are applied on packages.
Together with Error Prone and NullAway, it helps
protect the project against nullness bugs without hand-writing an annotated package-info.java for every package - see
examples/ for sample working setups, including Kotlin interop.
Apply plugin in build.gradle.kts.
plugins {
java
id("io.github.malczuuu.nullmarked") version "0.7.1"
}Configuration (all optional):
nullmarked {
// false makes the plugin inert: nothing is generated, nothing is verified and
// no JSpecify dependency is added
enabled = true
// true verifies instead of generating: no package-info.java is generated and
// the build fails on packages that do not declare one by hand
verifyOnly = false
// false omits the "Generated by ..." comment in generated files
headerEnabled = true
// version of the auto-added org.jspecify:jspecify dependency, or a full
// "group:name:version" notation to use a fork instead
jspecifyVersion = "1.0.1"
// which packages are processed; empty means all of them
packages {
exclude("com.acme.generated..")
}
// package-info.java verification strictness; lenient() is the default
verify {
lenient()
}
}Package identifiers follow ArchUnit's syntax:
org.acmematches onlyorg.acme,org.acme..matchesorg.acmeand all its subpackages,..internal..matches any package containing aninternalsegment,*matches within a single segment.
To configure packages used for package-info.java generation, use exclude(...) and include(...). They append to an
ordered list of rules, where the last rule matching a package decides whether it is processed. A package no matching
rule is automatically included. The include clause only exists to carve exceptions out of an earlier exclude:
nullmarked {
packages {
exclude("..internal..") // skip every internal package
include("com.acme.internal.api") // ... except this one
}
}A source set's own rules are evaluated after all top-level ones, no matter which block the build script declares first, so a source set can re-admit what the top level excluded:
nullmarked {
packages {
exclude("..internal..")
}
sourceSet("test") {
packages {
include("..internal..") // test sources keep their internal packages
}
}
}Every configured source set gets a verifyPackageInfo task that compileJava depends on. It fails the build listing
every package that has Java files but no package-info.java, counting hand-written and generated ones alike and
skipping whatever the packages { } rules exclude.
Set verifyOnly = true to opt out of generated code and only enforce hand-written package-info.java files:
nullmarked {
verifyOnly = true
}enabled |
verifyOnly |
Generation | Verification | JSpecify dependency |
|---|---|---|---|---|
true |
false |
on | on (passes) | added |
true |
true |
off, previous output is deleted | on (fails on gaps) | added |
false |
ignored | off, previous output is deleted | off | not added |
The verifyOnly mode still gets the JSpecify dependency, since hand-written package-info.java files need annotations
on the compile classpath. With enabled = false the plugin touches nothing at all, which is also the only way to opt
out of the auto-added dependency short of declaring JSpecify yourself.
verifyPackageInfo judges each package's package-info.java against a strictness level, configured with a
verify { } block:
nullmarked {
verify {
strict() // lenient() is the default, explicit() sits in between
}
}lenient()(default) - apackage-info.javaonly needs to exist; its content is not inspected.explicit()- it must also declare@NullMarkedor@NullUnmarked; a bare file fails.strict()- it must declare@NullMarkedspecifically;@NullUnmarkedor a bare file fails.
Both explicit() and strict() fail on a file declaring both annotations.
The same block works on a `sourceSet (...), overriding whatever it would otherwise inherit - whichever call happens last wins:
nullmarked {
verify {
strict()
}
sourceSet("test") {
verify {
lenient() // tests stay lenient
}
}
}Failures list a reason per offending package:
Found 2 package(s) with a package-info.java problem:
- com.acme: missing package-info.java
- com.acme.bare: package-info.java present but declares neither @NullMarked nor @NullUnmarked
Add a package-info.java to each of them, or exclude them via nullmarked.packages
Important
Note that verification is based on text analysis of package-info.java files, not the compiled output.
By default, only main is processed. Use sourceSet("...") to opt other source sets in, e.g. test:
nullmarked {
sourceSet("test") {
// same three properties as above, each defaulting to the top-level value
enabled = true
verifyOnly = false
headerEnabled = true
// rules appended after the top-level ones
packages {
exclude("com.acme.fixtures..")
}
}
}Each sourceSet block inherits enabled, verifyOnly and headerEnabled from the top-level nullmarked { }
configuration and only needs to set what it wants to override; its packages { } rules are appended to the top-level
ones. To opt a source set in without overriding anything, drop the block: sourceSet("test").
Opting in a name that no Java source set matches fails the build, listing the ones that exist, so a typo does not silently do nothing.
- Gradle 8.3 or later
- Java 8+
The plugin reacts to the java plugin and scans the Java source directories of each configured source set.
./gradlew # calls configured Gradle default tasks (spotlessApply and build)
./gradlew spotlessApply # format code: ktfmt for *.kt (Kotlin sources), ktlint for *.kts (Gradle buildscript)
./gradlew build # compiles the plugin and runs its unit tests
./gradlew integrationTest # TestKit compatibility test; -Pcompat.gradle.version=9.0.0 targets a specific GradleExpand...
- Build project to local Maven repository.
./gradlew publishToMavenLocal
- Add
mavenLocaltopluginManagementinsettings.gradle.kts.pluginManagement { repositories { gradlePluginPortal() mavenCentral() mavenLocal() } } - Apply plugin in
build.gradle.kts.plugins { java id("io.github.malczuuu.nullmarked") version "0.7.2-SNAPSHOT" }
This project is licensed under the Apache License, Version 2.0.
This project is not affiliated with, sponsored by, or endorsed by Gradle or JSpecify. All product names, logos, and brands are property of their respective owners.