diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index b02bf9f..dd6a91e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "java-guardrails", "displayName": "Java Guardrails", - "version": "0.2.0", + "version": "0.3.0", "description": "Deterministic quality gates (PMD, SpotBugs, Error Prone, ArchUnit, JaCoCo, NullAway, Lincheck, PIT) with calibrated settings and playbooks for installing them one at a time on an existing Java code base, so an agent refactors until the build is green.", "author": { "name": "Mark Pollack" }, "repository": "https://github.com/markpollack/java-guardrails", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a5765f4..66b1543 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,8 @@ jobs: echo "$out" | grep -qF -- "$l" || { echo "::error::plain measured: missing: $l"; fail=1; } done < fixtures/assess/plain/expected-measured.txt exit $fail + - name: NullAway compiler proof and negative controls + run: ./jbang tests/NullAwayTest.java . - name: SKILL.md validates against the Agent Skills spec run: npx -y skills-ref validate . - name: Manifests are valid JSON and point at the root diff --git a/CHANGELOG.md b/CHANGELOG.md index a51cb14..33402c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.0 (unreleased) + +- NullAway package inventory, report-mode measurement, planted-violation proof and adoption playbook. +- Optional NullAway routing from assess; explicit package scope, independent of cleanup of other Error Prone checks. + ## 0.2.0 — 2026-10-27 See `.github/release-notes/0.2.0.md`. diff --git a/README.md b/README.md index 83bc144..3ca7bdd 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,18 @@ base's own facts, which `assess` reads (below), not from the table. | Is executed code wrong in a known shape? | Error Prone defaults at ERROR | a compile under JDK 21; every change | **Proven end to end on two code bases**: measured, decided, installed, green, falsified. Three real bugs on the second. | | What must be refactored before the others can see it? | PMD size, complexity and duplication | minutes, then the refactoring; a target, then a gate | **Proven end to end on two code bases**: measured, decided, installed, green, falsified; measured on a third, where the references did not fit (`evidence/agent-journal.md`). | | Is the structure drifting? | ArchUnit no-cycles | seconds after compile; every change | **Proven on two code bases**; on the second, gating the cycle-free modules while the core's 13 measured cycles wait for their own session. Layers not yet. | -| | NullAway, Lincheck | | Outlines. | +| Can null cross a non-null contract? | NullAway, explicit package scope | a compile under JDK 21; every change | Measurement and falsification implemented; see `evidence/nullaway.md` for the scope proven. Optional, outside the default gate order. | +| | Lincheck | | Outline. | | Does the executing test check anything? (advanced) | PIT, scoped | tens of minutes whole-module, about two scoped; changed classes during work, whole module at acceptance | Playbook written from one code base's record; a profile, never a gate, never in the order. | Maven only. A Gradle slice is planned; `measure` already works on Gradle projects, the gate install does not. +For nullness checking, ask to adopt NullAway on a selected package (`playbooks/30-nullaway.md`). +It runs through Error Prone's compiler integration; you can select NullAway before cleaning up +other Error Prone checks. `measure-nullaway --inventory` lists package defaults without a build. +The report and planted-violation proof require the profile, JSpecify, and JDK 21+. + ## Getting started You need a JDK 17 or newer (21 or newer for the Error Prone gate), a Maven project with the @@ -182,6 +188,9 @@ taking the change is your decision. ./jbang falsify spotbugs ./jbang falsify jacoco ./jbang falsify archunit +./jbang measure-nullaway --inventory # package defaults, no build +./jbang measure-nullaway --module module-path +./jbang falsify nullaway --module module-path --package com.example ``` The aliases resolve from `jbang-catalog.json` in the current directory, so from anywhere else @@ -245,7 +254,7 @@ Next, in order: - **Gradle**: the measures already work on Gradle projects; the gate install does not. - **ArchUnit, the rest**: breaking cycles that run through public types, and a layering rule the owner confirms. -- **Tier 3**: NullAway and Lincheck from the first code base's record; PIT's scoped playbook +- **Tier 3**: Lincheck from the first code base's record; PIT's scoped playbook is written, a script to run it is not. - **Windows** proof of the scripts, and a self-test fixture so CI runs the measures and the plants against known counts. diff --git a/SKILL.md b/SKILL.md index a0ee7cc..5973db7 100644 --- a/SKILL.md +++ b/SKILL.md @@ -5,7 +5,7 @@ license: Business Source License 1.1; see LICENSE compatibility: Requires a JDK 17 or newer and a Maven project with the Maven wrapper. The bundled JBang wrapper resolves everything else on first run. Gradle is not yet supported. metadata: author: markpollack - version: "0.2.0" + version: "0.3.0" --- # java-guardrails @@ -48,13 +48,19 @@ owner edits the line. The tier says how proven a gate is, not when to install it | Error Prone defaults at ERROR | Is executed code wrong in a known shape? | a compile under JDK 21; every change | `playbooks/11-errorprone.md` | proven on two code bases | | PMD size, complexity and duplication | What must be refactored before the others can see it? | minutes, then the refactoring; a target, then a gate | `playbooks/10-pmd.md` | proven on two code bases; measured on a third | | ArchUnit no-cycles, then layers | Is the structure drifting? | seconds; every change | `playbooks/20-archunit.md` | no-cycles proven on two code bases; layers not yet | -| NullAway, Lincheck | tier 3 | | `playbooks/30-*.md`, `31-*.md` | outline | +| NullAway (optional, explicit package scope) | Can null cross a non-null contract? | a compile under JDK 21; every change | `playbooks/30-nullaway.md` | measurement and falsification implemented; see `evidence/nullaway.md` for proven scope | +| Lincheck | tier 3 | | `playbooks/31-lincheck.md` | outline | | PIT, scoped (advanced) | Does the executing test check anything? | tens of minutes whole-module, about two scoped; changed classes during work, whole module at acceptance | `playbooks/32-pit.md` | written from one code base's record; a profile, not a gate, never in the order | Follow a playbook step by step. Every step is marked **script**, **AI**, **notify** or **stop**. Do not write a gate's playbook from memory; an outline means the gate is not yet proven and you say so. +For NullAway, follow playbook 30: inventory package defaults, choose adoption scope, measure, +fix, and falsify each claimed package. It needs Error Prone's compiler integration; other Error +Prone findings need not be cleaned up first. NullAway is outside the default `GATE_ORDER` in this +increment. A configured processor with no marked scope, or a zero report count, is not proof. + ## Stops: notify or decide Some steps need the owner: which modules are test code, how to go green, which exemptions to @@ -105,7 +111,9 @@ is kept and ignored; `PROJECT_*` is never claimed by the kit, so the target's ow /jbang /scripts/SpotBugsMeasure.java [--top N] # needs the plugin slice installed /jbang /scripts/JacocoMeasure.java # needs nothing installed; runs the tests /jbang /scripts/ArchUnitMeasure.java [--top N] # needs nothing installed; compiles -/jbang /scripts/Falsify.java pmd | cpd | errorprone | spotbugs | jacoco | archunit +/jbang /scripts/NullAwayMeasure.java [--module path] [--inventory] # inventories without a build; otherwise needs the profile and JDK 21 +/jbang /scripts/Falsify.java pmd | cpd | errorprone | spotbugs | jacoco | archunit | nullaway +/jbang /scripts/Falsify.java nullaway [--module path] [--package com.example] # existing JSpecify-marked package ``` `assess` reads files and the git log, runs PMD and CPD in-process under a time limit diff --git a/configs/nullaway/package-info.java.tmpl b/configs/nullaway/package-info.java.tmpl new file mode 100644 index 0000000..f3bccc8 --- /dev/null +++ b/configs/nullaway/package-info.java.tmpl @@ -0,0 +1,3 @@ +// Replace com.example with the selected package. This default does not include subpackages. +@org.jspecify.annotations.NullMarked +package com.example; diff --git a/evidence/nullaway.md b/evidence/nullaway.md new file mode 100644 index 0000000..5f4269b --- /dev/null +++ b/evidence/nullaway.md @@ -0,0 +1,49 @@ +# NullAway: package adoption and gate proof + +## Local compiler fixture, 2026-10-27 + +`./jbang tests/NullAwayTest.java .` builds a temporary Maven project from the kit's actual +Error Prone profile, properties and JVM arguments, using JDK 21, Maven 3.9.9, Error Prone +2.50.0, NullAway 0.13.8 and JSpecify 1.0.0. + +The fixture has one marked package and one unmarked child package. A null returned from a +non-null method produces one unique NullAway report finding and fails the ordinary compile. +Fixing it produces zero findings; falsification then proves the gate with a new nullable return +inside the existing marked package. Disabling NullAway cannot pass that proof. Comment-only +markers, an unrelated compiler failure, and a colliding target-owned probe cannot pass either. +Sources remain unchanged after the probe, including negative runs. The fixture also compiles a +dropped exception with other Error Prone checks disabled, while the NullAway plant still fails: +other Error Prone cleanup is not a prerequisite for nullness adoption. + +The source inventory counts **exact package defaults**, not all checked declarations. It does +not recursively treat child packages as marked, or claim class/method annotation coverage. +`assess` reports processor presence and scope requiring proof; it retains the default gate order. + +## MCP Java SDK: one existing package, 2026-10-27 + +An isolated export of the previous trial target at `61a1db89a477f80783c8563bf68c2bba0c8e131b` +was used. No target checkout was modified or pushed. The scope was the two-file +`mcp-core=io.modelcontextprotocol.json.schema` package, with a new JSpecify package default and +the JSpecify dependency in `mcp-core`. The other nine core packages stayed unmarked. + +| Measurement | Result | +|---|---| +| Report-mode compile after marking | 3 unique NullAway diagnostics | +| Findings | Two factories construct a validation response with a null component; the default schema validator passes null to the successful-response factory | +| Fix | Mark the documented nullable response components and successful-response parameter `@Nullable`; also preserve the documented nullable schema parameter of `assertConforms` | +| Report-mode compile after annotation changes | 0 NullAway findings | +| `falsify nullaway --module mcp-core --package io.modelcontextprotocol.json.schema` | Build red, `[NullAway]` names the planted nullable return; probe removed | +| Normal restored `clean verify`, selected module and its dependencies | Green; 452 tests, zero failures/errors/skips; existing PMD/CPD, SpotBugs and coverage checks pass | + +Commands, invoked from the kit with the scratch target substituted for ``: + +``` +./jbang scripts/NullAwayMeasure.java --module mcp-core +./jbang scripts/Falsify.java nullaway --module mcp-core --package io.modelcontextprotocol.json.schema +/mvnw -B -P errorprone -Dspring-javaformat.skip=true -pl mcp-core -am clean verify +``` + +Prediction: no existing assess rule predicted these findings. Package marking exposed incomplete +nullability contracts already documented in the API; this experiment establishes annotation and +enforcement mechanics, not three new runtime bugs. It proves only the selected package, not +whole-SDK null safety or nullness across unmarked module boundaries. diff --git a/fixtures/nullaway/.mvn/wrapper/maven-wrapper.properties b/fixtures/nullaway/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..d58dfb7 --- /dev/null +++ b/fixtures/nullaway/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# 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. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/fixtures/nullaway/README.md b/fixtures/nullaway/README.md new file mode 100644 index 0000000..8e104e1 --- /dev/null +++ b/fixtures/nullaway/README.md @@ -0,0 +1,17 @@ +# NullAway compiler proof + +Run from the kit root on JDK 21+: + +``` +./jbang tests/NullAwayTest.java . +``` + +The Java runner creates an isolated temporary Maven target using the shipped Error Prone profile, +properties and JVM arguments. These wrapper files are Apache Maven Wrapper 3.3.2 (Apache-2.0), +using Maven 3.9.9. The runner keeps its scratch directory for inspection and prints the path. + +It proves a real NullAway diagnostic appears in report mode, the normal compile fails, fixing the +source clears it, and falsification catches a planted nullable return. Negative controls disable +the checker, remove the package annotation, collide with an existing target file and break the +build. None can pass proof or overwrite target sources. An unmarked child package stays outside +its parent's package default. The new probe is removed after both positive and negative runs. diff --git a/fixtures/nullaway/mvnw b/fixtures/nullaway/mvnw new file mode 100755 index 0000000..19529dd --- /dev/null +++ b/fixtures/nullaway/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/fixtures/nullaway/mvnw.cmd b/fixtures/nullaway/mvnw.cmd new file mode 100644 index 0000000..b150b91 --- /dev/null +++ b/fixtures/nullaway/mvnw.cmd @@ -0,0 +1,149 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' +$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" +if ($env:MAVEN_USER_HOME) { + $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" +} +$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/jbang-catalog.json b/jbang-catalog.json index 5c8e575..136bb7d 100644 --- a/jbang-catalog.json +++ b/jbang-catalog.json @@ -10,7 +10,7 @@ }, "falsify": { "script-ref": "scripts/Falsify.java", - "description": "Plant a violation for a named gate (pmd | cpd | errorprone | spotbugs) in a gated module, run that module's check, restore the file, and pass only if the build went red and the report names the plant" + "description": "Plant a violation for pmd, cpd, errorprone, spotbugs, jacoco, archunit or nullaway; pass only when the build fails and names the plant, then restore sources" }, "measure-errorprone": { "script-ref": "scripts/ErrorProneMeasure.java", @@ -27,6 +27,10 @@ "measure-archunit": { "script-ref": "scripts/ArchUnitMeasure.java", "description": "Import each gated module's compiled classes and evaluate the kit's two no-cycles rules (packages at any depth; classes within a package); print every cycle with the dependencies that close it, and the ARCHUNIT_CYCLES stop" + }, + "measure-nullaway": { + "script-ref": "scripts/NullAwayMeasure.java", + "description": "Inventory JSpecify package defaults and measure unique NullAway findings in report mode; --inventory needs no build, --module scopes the compile" } } } diff --git a/maven/jspecify.dependency.xml b/maven/jspecify.dependency.xml new file mode 100644 index 0000000..848e190 --- /dev/null +++ b/maven/jspecify.dependency.xml @@ -0,0 +1,7 @@ + + + org.jspecify + jspecify + 1.0.0 + diff --git a/playbooks/00-assess.md b/playbooks/00-assess.md index db50226..27b4993 100644 --- a/playbooks/00-assess.md +++ b/playbooks/00-assess.md @@ -68,6 +68,11 @@ test-named classes) are left out of the measurement and of the test counts unles file says otherwise. PIT is advanced and not in the order. The table shows it, and the scoped command is printed when a profile exists (`playbooks/32-pit.md`). +NullAway is an optional package-adoption step (`playbooks/30-nullaway.md`), outside the default +order in this increment. `assess` lists package defaults and processor presence; neither proves +the scope is enforced. Use `measure-nullaway` and `falsify nullaway` to establish that. Error +Prone's compiler integration is required; cleanup of unrelated Error Prone checks is not. + ## The questions, the costs, the cadences | Question | Gate | Cost | Cadence | diff --git a/playbooks/30-nullaway.md b/playbooks/30-nullaway.md index 00f25db..69e37bd 100644 --- a/playbooks/30-nullaway.md +++ b/playbooks/30-nullaway.md @@ -1,6 +1,93 @@ # NullAway, one package at a time -> Status: not written. The procedure below is the outline; each numbered step will say whether it is a script, an AI task or a stop for the owner. +> Status: measurement and falsification implemented; local compiler fixtures prove the gate and +> negative controls. Maven, JDK 21+. See `evidence/nullaway.md` for the scope actually tried. -- Prerequisite: playbook 11. -- AI: add JSpecify; mark one package `@NullMarked`; fix; repeat. OnlyNullMarked keeps unmarked packages unchecked. +NullAway is an Error Prone checker. It needs Error Prone's **compiler integration**, not a +completed cleanup of every other Error Prone finding. The kit's existing `errorprone` profile +already puts NullAway on the processor path, enables `NullAway:ERROR`, and sets +`JSpecifyMode=true` and `OnlyNullMarked=true`. With no marked code, that configuration can +compile green while checking none of your packages. Do not count that as an installed gate. + +This is an optional adoption step outside the default `GATE_ORDER`: choose the nullness scope +explicitly. Run `measure-nullaway --inventory` before deciding which package to mark. Existing +recorded gate orders continue to work. + +## Steps + +1. **Script** — inventory, which needs no installed plugin or build: + + ``` + /jbang /scripts/NullAwayMeasure.java --inventory + ``` + + It lists `module=package`, file counts, and JSpecify package defaults. The inventory counts + exact package annotations, not class/method markers or `@NullUnmarked` exceptions. A package + default does not apply to child packages. This first increment does not measure annotation + coverage inside methods and classes. +2. **AI** — use the compiler integration from playbook 11 if absent: the profile in + `maven/errorprone.profile.xml`, its Error Prone/NullAway properties, and `maven/jvm.config`. + Match the target's main compile execution ID; preserve its other annotation processors. + Tests stay outside the main-source execution. This increment supports the kit's root-POM + `errorprone` profile. Add `maven/jspecify.dependency.xml` to each module whose sources will + import the annotations; dependency management alone does not put annotations on the classpath. + JSpecify mode on JDK 21 needs `-XDaddTypeAnnotationsToSymbol=true`, already in the slice. +3. **AI** — choose a small package with the owner within the requested adoption scope, using + the inventory. Add `configs/nullaway/package-info.java.tmpl` as its `package-info.java`, with + the real package name. When the owner already named a package, use that authorization. + Do not mark every package merely to improve a count. Public nullable contracts often need + annotation changes before implementations; retain the behavior callers rely on. +4. **Script** — measure the selected module in report mode: + + ``` + /jbang /scripts/NullAwayMeasure.java --module module-path + ``` + + Omit `--module` to compile the reactor. The script runs `clean compile` through the target's + Maven wrapper, with the `errorprone` profile and dependencies of the selected module (`-am`). + It demotes errors to warnings for this invocation, groups unique NullAway diagnostics by + file and line, and exits nonzero when the report-mode build fails. It changes no source or + POM. Other checks may still report; fix build failures before trusting the count. A zero + count alone cannot show that the checker ran. +5. **AI** — fix the marked package. Use `org.jspecify.annotations.Nullable` on genuinely + nullable parameters, returns, record components and type arguments; check before dereference. + Annotate the declaration that owns the contract, including interfaces. Do not introduce a + null check that changes an intentionally nullable API into a rejecting one. Add behavioral + tests for discovered bugs. Narrow, explained configuration exceptions require evidence; + neither removing the marker nor adding blanket suppressions is a fix. +6. **Script** — prove the gate on that **existing** package: + + ``` + /jbang /scripts/Falsify.java nullaway --module module-path --package com.example.api + ``` + + It creates a reserved probe with a nullable return in the selected marked package, + runs the real fail-mode compile, and removes the probe in `finally`. It does not add a + marker or enable a disabled checker to manufacture success. It passes only when the build + fails and a `[NullAway]` diagnostic on the probe names the nullable return. A green build, + missing marker, unrelated failure, or existing probe file cannot pass. Without selectors it + uses the first marked package in path order; explicitly select each package you claim proven. +7. **Script** — after restoration, run the target's normal tests and gated compile. `clean` + removes any compiled probe left by a negative control. Re-measure and record the package + scope, findings, fixes, full command and planted-failure proof in evidence; state whether an + assess fact predicted each finding. Claim only the scope falsified. Commit that package's + adoption, then repeat for the next one. + +## Enabling NullAway selectively + +For a target that wants nullness checking before triaging the other compiler checks, retain the +Error Prone integration but use this ordered plugin argument instead of its default-check list: + +``` +-Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:JSpecifyMode=true -XepOpt:NullAway:OnlyNullMarked=true ${errorprone.extraArgs} +``` + +Keep the other compiler arguments and both processor dependencies from the slice. This is an +explicit selection of NullAway; do not apply it over a target's already accepted Error Prone gate. +Report mode remains `errorprone.failOnWarning=false` plus +`errorprone.extraArgs=-XepAllErrorsAsWarnings`; the script passes these for that run only. + +## References + +- [NullAway configuration](https://github.com/uber/NullAway/wiki/Configuration): checker flags and Maven integration. +- [JSpecify support](https://github.com/uber/NullAway/wiki/JSpecify-Support): mode and JDK requirements. diff --git a/scripts/Assess.java b/scripts/Assess.java index 0eb43bd..073558b 100644 --- a/scripts/Assess.java +++ b/scripts/Assess.java @@ -4,6 +4,7 @@ //DEPS org.slf4j:slf4j-simple:1.7.36 //SOURCES Util.java //SOURCES PmdMeasure.java +//SOURCES NullAwaySupport.java import java.io.IOException; import java.nio.file.Files; @@ -46,7 +47,7 @@ public class Assess { TOOLING.put("maven-pmd-plugin", "PMD (the kit's gate)"); TOOLING.put("spotbugs-maven-plugin", "SpotBugs (the kit's gate)"); TOOLING.put("error_prone_core", "Error Prone (the kit's gate)"); - TOOLING.put("nullaway", "NullAway (tier 3)"); + TOOLING.put("nullaway", "NullAway (package-by-package; uses Error Prone integration)"); TOOLING.put("jacoco-maven-plugin", "JaCoCo (the kit's gate)"); TOOLING.put("archunit", "ArchUnit (the kit's gate)"); TOOLING.put("pitest-maven", "PIT (advanced; a scoped profile, not a gate)"); @@ -268,6 +269,8 @@ else if (measured.isEmpty()) { System.out.println("| " + g.name() + " (" + g.question() + ") | " + state.get(g.key()).label + " | " + finds.get(g.key()) + " | " + cost.get(g.key()) + " |"); } System.out.println("| PIT, scoped (advanced: does the executing test check anything?) | " + (pit ? "profile present" : "absent") + " | not a gate; reads survivors in classes that changed | tens of minutes whole-module, about two scoped |"); + System.out.println("| NullAway (can null cross a non-null contract?) | " + (found.containsKey("nullaway") ? "processor configured; scope needs proof" : "absent") + + " | " + NullAwaySupport.summary(NullAwaySupport.packages(target, "")) + " | package-by-package adoption; `measure-nullaway --inventory`, then playbook 30 |"); System.out.println(); System.out.println("- Churn (main-source files touched most in the last 50 commits): " + (churn.isEmpty() ? "no git history read" : String.join(", ", churn))); System.out.println(); @@ -343,6 +346,9 @@ else if (next.equals("pmd") && pmdRuleset && state.get("pmd") != State.ABSENT && System.out.println(); System.out.println(scopedPit(target, modules)); } + System.out.println(); + System.out.println("Optional NullAway adoption: `playbooks/30-nullaway.md`. It needs Error Prone's compiler integration, " + + "not a completed cleanup of every Error Prone finding. Choose the package scope explicitly; it is outside the default GATE_ORDER."); System.exit(0); // a timed-out PMD run may still be on a daemon thread } diff --git a/scripts/Falsify.java b/scripts/Falsify.java index b56d57a..53e885d 100644 --- a/scripts/Falsify.java +++ b/scripts/Falsify.java @@ -1,6 +1,7 @@ ///usr/bin/env jbang "$0" "$@" ; exit $? //JAVA 17+ //SOURCES Util.java +//SOURCES NullAwaySupport.java import java.io.IOException; import java.nio.file.Files; @@ -13,15 +14,19 @@ * step that should fail, and restoring the file byte for byte. Exit 0 means the gate caught the * plant (red as expected); exit 1 means the build stayed green, so the gate checks nothing. * - * Usage: {@code ./jbang falsify [target] } where gate is {@code pmd}, {@code cpd}, {@code errorprone}, {@code spotbugs}, {@code jacoco} or {@code archunit}. + * Usage: {@code ./jbang falsify } where gate is {@code pmd}, {@code cpd}, {@code errorprone}, {@code spotbugs}, {@code jacoco}, {@code archunit} or {@code nullaway}. */ public class Falsify { public static void main(String[] args) throws Exception { Path target = Util.target(args); String gate = args.length > 0 ? args[args.length - 1] : ""; + if (args.length > 1 && args[1].equals("nullaway")) { + falsifyNullAway(target, args); + return; + } if (!List.of("pmd", "cpd", "errorprone", "spotbugs", "jacoco", "archunit").contains(gate)) { - System.err.println("falsify: name the gate to falsify: pmd | cpd | errorprone | spotbugs | jacoco | archunit"); + System.err.println("falsify: name the gate to falsify: pmd | cpd | errorprone | spotbugs | jacoco | archunit | nullaway"); System.exit(2); } if (gate.equals("archunit")) { @@ -68,6 +73,48 @@ public static void main(String[] args) throws Exception { System.out.println("falsify " + gate + ": OK, build red (exit " + exit + ") and the planted violation was reported by " + fired); } + /** Plant in an EXISTING marked package: adding a marker to the probe would hide missing adoption scope. */ + static void falsifyNullAway(Path target, String[] args) throws Exception { + NullAwaySupport.requireProfile(target); + String selectedModule = NullAwaySupport.option(args, "--module"); + String selectedPackage = NullAwaySupport.option(args, "--package"); + var scope = NullAwaySupport.packages(target, selectedModule).stream() + .filter(NullAwaySupport.PackageScope::marked) + .filter(p -> selectedPackage.isEmpty() || p.name().equals(selectedPackage)).findFirst(); + if (scope.isEmpty()) { + System.err.println("falsify nullaway: NOT PROVEN; no selected package has JSpecify @NullMarked. " + + "Choose and mark a package first (playbook 30). No sources changed."); + System.exit(1); + } + var pkg = scope.orElseThrow(); + Path probe = pkg.directory().resolve("GuardrailsNullAwayProbe.java"); + String source = (pkg.name().isEmpty() ? "" : "package " + pkg.name() + ";\n") + + "// planted by java-guardrails falsify: must never be committed\n" + + "final class GuardrailsNullAwayProbe {\n" + + " static String guardrailsPlantedViolation() { return null; }\n}\n"; + // CREATE_NEW prevents overwriting a target-owned file, and cleanup happens only after successful creation. + Files.writeString(probe, source, java.nio.file.StandardOpenOption.CREATE_NEW); + StringBuilder output = new StringBuilder(); + int exit; + try { + System.out.println("falsify nullaway: planting in " + pkg.key()); + exit = NullAwaySupport.compile(target, pkg.module().path(), false, output); + } + finally { + Files.delete(probe); + System.out.println("falsify nullaway: removed the planted class"); + } + boolean named = output.toString().lines().anyMatch(line -> line.contains("GuardrailsNullAwayProbe.java:[") + && line.contains("[NullAway]") && line.contains("returning @Nullable")); + if (exit == 0 || !named) { + System.err.println(output); + System.err.println("falsify nullaway: FAILED; build exit " + exit + ", planted NullAway diagnostic " + + (named ? "reported" : "not reported") + "; the selected scope is NOT PROVEN"); + System.exit(1); + } + System.out.println("falsify nullaway: OK, build red and [NullAway] reported the planted nullable return in " + pkg.key()); + } + /** The rules that reported the planted member, from the module's PMD or CPD report, or the compiler output for Error Prone. */ static List rulesFired(String gate, String xml, Path plantedFile) { if (gate.equals("cpd")) { diff --git a/scripts/NullAwayMeasure.java b/scripts/NullAwayMeasure.java new file mode 100644 index 0000000..917d0b6 --- /dev/null +++ b/scripts/NullAwayMeasure.java @@ -0,0 +1,61 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 21+ +//SOURCES Util.java +//SOURCES NullAwaySupport.java + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Report NullAway findings and adoption scope without adding annotations or weakening the gate. */ +public class NullAwayMeasure { + + // Maven prints compiler findings twice; count unique source locations and messages. + static final Pattern DIAGNOSTIC = Pattern.compile("^\\[(?:WARNING|ERROR)\\] (.+\\.java):\\[(\\d+),\\d+\\] \\[NullAway\\] (.*)$"); + + public static void main(String[] args) throws Exception { + if (java.util.List.of(args).contains("--help")) { + System.out.println("Usage: measure-nullaway [--module module-path] [--inventory]"); + return; + } + Path target = Util.target(args); + String module = NullAwaySupport.option(args, "--module"); + var packages = NullAwaySupport.packages(target, module); + System.out.println("# NullAway measurement: " + target.getFileName()); + System.out.println(NullAwaySupport.summary(packages)); + System.out.println("| module=package | main files | package default |"); + System.out.println("|---|---:|---|"); + for (var scope : packages) { + System.out.println("| " + scope.key() + " | " + scope.files() + " | " + (scope.marked() ? "@NullMarked" : "unmarked") + " |"); + } + if (java.util.List.of(args).contains("--inventory")) { + return; + } + NullAwaySupport.requireProfile(target); + StringBuilder output = new StringBuilder(); + int exit = NullAwaySupport.compile(target, module, true, output); + Set findings = new LinkedHashSet<>(); + for (String line : output.toString().split("\\R")) { + Matcher match = DIAGNOSTIC.matcher(line); + if (match.matches()) { + Path file = Path.of(match.group(1)).toAbsolutePath().normalize(); + findings.add(target.relativize(file) + ":" + match.group(2) + " " + match.group(3)); + } + } + System.out.println("\nNullAway findings: " + findings.size()); + findings.forEach(finding -> System.out.println("- " + finding)); + if (exit != 0) { + System.err.println(output); + System.err.println("measure-nullaway: report-mode compile failed (exit " + exit + "); findings are partial. Fix the build first."); + System.exit(1); + } + System.out.println("Report-mode compile passed. A zero count does not prove NullAway ran: " + + "run falsify nullaway on an existing marked package before claiming a gate."); + if (packages.stream().noneMatch(NullAwaySupport.PackageScope::marked)) { + System.out.println("No package defaults are marked. Choose one package using playbook 30; do not claim whole-project null safety."); + } + } +} diff --git a/scripts/NullAwaySupport.java b/scripts/NullAwaySupport.java new file mode 100644 index 0000000..412528c --- /dev/null +++ b/scripts/NullAwaySupport.java @@ -0,0 +1,122 @@ +//JAVA 17+ +//SOURCES Util.java + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeMap; +import java.util.regex.Pattern; + +/** Shared scope inventory and Maven invocation for package-by-package NullAway adoption. */ +final class NullAwaySupport { + + private NullAwaySupport() { + } + + record PackageScope(Util.Module module, String name, Path directory, int files, boolean marked) { + String key() { + return module.path() + "=" + name; + } + } + + static String option(String[] args, String name) { + for (int i = 0; i < args.length; i++) { + if (args[i].equals(name)) { + if (i + 1 == args.length || args[i + 1].startsWith("--")) { + throw new IllegalArgumentException(name + " needs a value"); + } + return args[i + 1]; + } + } + return ""; + } + + /** Package annotations apply to this package only, never implicitly to child packages. */ + static List packages(Path target, String selectedModule) throws IOException { + List result = new ArrayList<>(); + List excluded = Util.Decisions.read(target).excludedModules(); + boolean moduleFound = selectedModule.isEmpty(); + for (Util.Module module : Util.modules(target)) { + if (!selectedModule.isEmpty() && !module.path().equals(selectedModule)) { + continue; + } + moduleFound = true; + if (excluded.stream().anyMatch(x -> module.path().equals(x) || module.path().startsWith(x + "/"))) { + continue; + } + TreeMap> byPackage = new TreeMap<>(); + for (Path file : Util.javaFiles(module.sourceRoot())) { + if (file.getFileName().toString().equals("module-info.java")) { + continue; + } + String pkg = module.sourceRoot().relativize(file.getParent()).toString().replace(java.io.File.separatorChar, '.'); + byPackage.computeIfAbsent(pkg, x -> new ArrayList<>()).add(file); + } + for (var entry : byPackage.entrySet()) { + Path directory = entry.getValue().get(0).getParent(); + Path info = directory.resolve("package-info.java"); + result.add(new PackageScope(module, entry.getKey(), directory, entry.getValue().size(), + Files.isRegularFile(info) && packageMarked(Files.readString(info)))); + } + } + if (!moduleFound) { + throw new IllegalArgumentException("no main-source module named " + selectedModule + " (use the module path from assess)"); + } + return result; + } + + static boolean packageMarked(String source) { + String text = source.replaceAll("(?s)/\\*.*?\\*/|//[^\\r\\n]*", ""); + var declaration = Pattern.compile("\\bpackage\\s+").matcher(text); + if (!declaration.find()) { + return false; + } + String annotations = text.substring(0, declaration.start()); + return Pattern.compile("@org\\.jspecify\\.annotations\\.NullMarked\\b").matcher(annotations).find() + || (Pattern.compile("@NullMarked\\b").matcher(annotations).find() + && Pattern.compile("import\\s+org\\.jspecify\\.annotations\\.(NullMarked|\\*)\\s*;").matcher(text).find()); + } + + /** Inventory only: a processor or a marker is not evidence of a live gate. */ + static String summary(List packages) { + long marked = packages.stream().filter(PackageScope::marked).count(); + return marked + " of " + packages.size() + " main-source packages have JSpecify @NullMarked; " + + "class/method annotations and @NullUnmarked exceptions are not counted; prove the selected scope with falsify nullaway"; + } + + static void requireProfile(Path target) throws IOException { + Path pom = target.resolve("pom.xml"); + String xml = Files.isRegularFile(pom) ? Files.readString(pom).replaceAll("(?s)", "") : ""; + if (!xml.contains("errorprone") || !xml.contains("-Xep:NullAway:")) { + throw new IllegalArgumentException("install the errorprone profile with NullAway first (playbooks/30-nullaway.md); " + + "the first increment supports the kit's root-POM profile"); + } + } + + static int compile(Path target, String module, boolean report, StringBuilder output) throws IOException, InterruptedException { + List command = new ArrayList<>(List.of(Util.mvnw(target), "-B", "-Dstyle.color=never", "-P", "errorprone", + "-Dspring-javaformat.skip=true")); + if (report) { + command.add("-Derrorprone.failOnWarning=false"); + command.add("-Derrorprone.extraArgs=-XepAllErrorsAsWarnings"); + } + if (!module.isEmpty()) { + command.addAll(List.of("-pl", module, "-am")); + } + command.addAll(List.of("clean", "compile")); + System.out.println("Running: " + String.join(" ", command)); + Process process = new ProcessBuilder(command).directory(target.toFile()).redirectErrorStream(true).start(); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + output.append(line).append('\n'); + } + } + return process.waitFor(); + } +} diff --git a/tests/NullAwayTest.java b/tests/NullAwayTest.java new file mode 100644 index 0000000..7fabdb3 --- /dev/null +++ b/tests/NullAwayTest.java @@ -0,0 +1,122 @@ +///usr/bin/env jbang "$0" "$@" ; exit $? +//JAVA 21+ +//SOURCES ../scripts/Util.java +//SOURCES ../scripts/NullAwaySupport.java + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Integration proof using the shipped Maven slice and real compiler, including negative controls. */ +public class NullAwayTest { + + record Result(int exit, String output) { + } + + static Path kit; + static Path target; + + public static void main(String[] args) throws Exception { + kit = Path.of(args.length == 0 ? "." : args[0]).toAbsolutePath(); + target = Files.createTempDirectory("guardrails-nullaway-"); + System.out.println("Fixture: " + target); + Files.createDirectories(target.resolve(".mvn/wrapper")); + for (String name : List.of("mvnw", "mvnw.cmd", ".mvn/wrapper/maven-wrapper.properties")) { + Files.copy(kit.resolve("fixtures/nullaway").resolve(name), target.resolve(name)); + } + target.resolve("mvnw").toFile().setExecutable(true); + Files.copy(kit.resolve("maven/jvm.config"), target.resolve(".mvn/jvm.config")); + String profile = Files.readString(kit.resolve("maven/errorprone.profile.xml")) + .replace(" -XepOpt:CheckReturnValue:Packages=reactor.core.publisher", "") + .replace(" -XepOpt:CheckReturnValue:ApiExclusionList=${maven.multiModuleProjectDirectory}/config/errorprone/reactor-ignorable-results.txt", ""); + String pom = """ + + 4.0.0 + fixturenullaway1 + 17 + %s + org.jspecifyjspecify + 1.0.0 + org.apache.maven.plugins + maven-compiler-plugin3.11.0 + + %s + """.formatted(Files.readString(kit.resolve("maven/properties.xml")), profile); + Files.writeString(target.resolve("pom.xml"), pom); + Path pkg = target.resolve("src/main/java/fx"); + Files.createDirectories(pkg.resolve("child")); + Files.writeString(pkg.resolve("package-info.java"), "@org.jspecify.annotations.NullMarked\npackage fx;\n"); + Path example = pkg.resolve("Example.java"); + String broken = "package fx; public final class Example { public String value() { return null; } }\n"; + String green = broken.replace("return null", "return \"ok\""); + Files.writeString(example, broken); + // This child package is deliberately unmarked: package annotations are not recursive. + Files.writeString(pkg.resolve("child/Unmarked.java"), + "package fx.child; public final class Unmarked { public String value() { return null; } }\n"); + Result measured = script("NullAwayMeasure.java"); + require(measured.exit == 0 && measured.output.contains("NullAway findings: 1"), "one unique report-mode finding", measured); + require(measured.output.contains("1 of 2 main-source packages"), "only the exact marked package counted", measured); + Result assessment = script("Assess.java", "--quick"); + require(assessment.exit == 0 && assessment.output.contains("processor configured; scope needs proof") + && assessment.output.contains("Record as: GATE_ORDER: errorprone, spotbugs, pmd, archunit, jacoco"), + "assessment shows optional adoption without treating processor presence as enforcement", assessment); + StringBuilder build = new StringBuilder(); + int exit = NullAwaySupport.compile(target, ".", false, build); + require(exit != 0 && build.toString().contains("[NullAway]"), "real nullness violation fails compile", new Result(exit, build.toString())); + Files.writeString(example, green); + Result zero = script("NullAwayMeasure.java"); + require(zero.exit == 0 && zero.output.contains("NullAway findings: 0"), "fixed source measures zero", zero); + Result proof = script("Falsify.java", "nullaway", "--module", ".", "--package", "fx"); + require(proof.exit == 0 && proof.output.contains("falsify nullaway: OK"), "marked scope catches planted nullable return", proof); + require(Files.readString(example).equals(green) && !Files.exists(pkg.resolve("GuardrailsNullAwayProbe.java")), "source restored after plant", proof); + Files.writeString(target.resolve("pom.xml"), pom.replace("-Xep:NullAway:ERROR", "-Xep:NullAway:OFF")); + Result off = script("Falsify.java", "nullaway"); + require(off.exit != 0 && off.output.contains("falsify nullaway: FAILED"), "disabled checker cannot pass proof", off); + require(!Files.exists(pkg.resolve("GuardrailsNullAwayProbe.java")), "negative control restores source", off); + String selective = pom.replaceAll("(?s)-Xplugin:ErrorProne.*?", + "-Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -XepOpt:NullAway:JSpecifyMode=true -XepOpt:NullAway:OnlyNullMarked=true \\${errorprone.extraArgs}"); + Files.writeString(target.resolve("pom.xml"), selective); + Files.writeString(example, green.replace("public String value()", "public void unrelated() { new IllegalArgumentException(\"dropped\"); } public String value()")); + StringBuilder selectedOutput = new StringBuilder(); + int selectedExit = NullAwaySupport.compile(target, ".", false, selectedOutput); + require(selectedExit == 0, "NullAway-only compile can precede other Error Prone cleanup", new Result(selectedExit, selectedOutput.toString())); + Result selectiveProof = script("Falsify.java", "nullaway"); + require(selectiveProof.exit == 0, "selective checker still catches the planted nullness violation", selectiveProof); + Files.writeString(target.resolve("pom.xml"), pom); + Files.writeString(example, green); + Files.writeString(pkg.resolve("GuardrailsNullAwayProbe.java"), "target-owned sentinel"); + Result collision = script("Falsify.java", "nullaway"); + require(collision.exit != 0 && Files.readString(pkg.resolve("GuardrailsNullAwayProbe.java")).equals("target-owned sentinel"), + "existing target file preserved", collision); + Files.delete(pkg.resolve("GuardrailsNullAwayProbe.java")); + Files.writeString(pkg.resolve("package-info.java"), "// @org.jspecify.annotations.NullMarked is only a comment\npackage fx;\n"); + Result unmarked = script("Falsify.java", "nullaway"); + require(unmarked.exit != 0 && unmarked.output.contains("NOT PROVEN"), "comment-only marker cannot pass proof", unmarked); + require(!Files.exists(pkg.resolve("GuardrailsNullAwayProbe.java")), "unmarked scope left untouched", unmarked); + Files.writeString(example, "package fx; public class Example { broken Java syntax }\n"); + Result failed = script("NullAwayMeasure.java"); + require(failed.exit != 0 && failed.output.contains("findings are partial"), "broken build never reports success", failed); + Files.writeString(pkg.resolve("package-info.java"), "@org.jspecify.annotations.NullMarked\npackage fx;\n"); + Result unrelated = script("Falsify.java", "nullaway"); + require(unrelated.exit != 0 && unrelated.output.contains("falsify nullaway: FAILED"), "unrelated compile failure cannot pass proof", unrelated); + require(!Files.exists(pkg.resolve("GuardrailsNullAwayProbe.java")), "unrelated failure restores source", unrelated); + System.out.println("PASS NullAway integration proof; fixture retained at " + target); + } + + static Result script(String script, String... options) throws Exception { + List command = new ArrayList<>(List.of(kit.resolve(System.getProperty("os.name").startsWith("Windows") ? "jbang.cmd" : "jbang").toString(), + kit.resolve("scripts").resolve(script).toString(), target.toString())); + command.addAll(List.of(options)); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + String output = new String(process.getInputStream().readAllBytes(), java.nio.charset.StandardCharsets.UTF_8); + return new Result(process.waitFor(), output); + } + + static void require(boolean condition, String name, Result result) { + if (!condition) { + throw new AssertionError(name + " (exit " + result.exit + ")\n" + result.output); + } + System.out.println("PASS " + name); + } +}