diff --git a/.github/workflows/check-since-tags.yml b/.github/workflows/check-since-tags.yml new file mode 100644 index 0000000000..8b9dd3bb3b --- /dev/null +++ b/.github/workflows/check-since-tags.yml @@ -0,0 +1,41 @@ +name: Check @since tags + +# Fails the build if any Java source under CodenameOne/src carries an @since +# javadoc tag whose version does not correspond to an existing git tag in +# this repository. Without this gate, prose can advertise APIs as available +# in versions that never shipped (e.g. "@since 9.0"). + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + branches: + - master + paths: + - 'CodenameOne/src/**/*.java' + - 'scripts/check-since-tags.sh' + - '.github/workflows/check-since-tags.yml' + push: + branches: + - master + paths: + - 'CodenameOne/src/**/*.java' + - 'scripts/check-since-tags.sh' + - '.github/workflows/check-since-tags.yml' + +jobs: + check-since-tags: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history including all tags so `git tag --list` returns + # the complete set of released versions. + fetch-depth: 0 + fetch-tags: true + + - name: Validate @since references against git tags + shell: bash + run: scripts/check-since-tags.sh diff --git a/.github/workflows/errorprone.yml b/.github/workflows/errorprone.yml new file mode 100644 index 0000000000..1f4938b466 --- /dev/null +++ b/.github/workflows/errorprone.yml @@ -0,0 +1,60 @@ +name: Error Prone checks + +# Runs the framework's custom Error Prone BugCheckers (currently just +# BanClassForName) over CodenameOne/src. The check fails the build when +# someone adds a Class.forName(...) call, which silently breaks on +# ParparVM (iOS) because the iOS port cannot resolve classes by string +# name at runtime. + +permissions: + contents: read + +on: + workflow_dispatch: + pull_request: + branches: + - master + paths: + - 'CodenameOne/src/**/*.java' + - 'maven/errorprone-checks/**' + - 'maven/core/pom.xml' + - 'maven/pom.xml' + - '.github/workflows/errorprone.yml' + push: + branches: + - master + paths: + - 'CodenameOne/src/**/*.java' + - 'maven/errorprone-checks/**' + - 'maven/core/pom.xml' + - 'maven/pom.xml' + - '.github/workflows/errorprone.yml' + +jobs: + errorprone: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + # Error Prone requires JDK 11+; we pick 17 to match the + # framework's Android-port JAVA17_HOME requirement. + distribution: temurin + java-version: 17 + cache: maven + + - name: Install errorprone-checks + working-directory: maven + # The checker module must be installed to the local repo BEFORE + # core compiles. We can't rely on the reactor ordering: core + # references errorprone-checks only via annotationProcessorPaths, + # which Maven does not treat as a build-time dependency, so the + # reactor would otherwise schedule core first and fail to resolve + # com.codenameone:errorprone-checks:8.0-SNAPSHOT. + run: mvn -B -Perrorprone -pl errorprone-checks -am install -DskipTests + + - name: Run Error Prone over the core module + working-directory: maven + run: mvn -B -Perrorprone -pl core -am install -DskipTests diff --git a/CodenameOne/src/com/codename1/location/GeofenceManager.java b/CodenameOne/src/com/codename1/location/GeofenceManager.java index 7314632f74..914f609a07 100644 --- a/CodenameOne/src/com/codename1/location/GeofenceManager.java +++ b/CodenameOne/src/com/codename1/location/GeofenceManager.java @@ -253,6 +253,9 @@ public boolean isBubble(String id) { } /// Gets the currently registered Listener class. + // The listener class name is persisted to Storage during registration; we + // have no Class literal here, so reflective resolution is unavoidable. + @SuppressWarnings("BanClassForName") public synchronized Class getListenerClass() { if (listenerClass == null) { String className = (String) Storage.getInstance().readObject(LISTENER_CLASS_KEY); diff --git a/CodenameOne/src/com/codename1/system/NativeLookup.java b/CodenameOne/src/com/codename1/system/NativeLookup.java index 5e9dafba74..af8e5958b1 100644 --- a/CodenameOne/src/com/codename1/system/NativeLookup.java +++ b/CodenameOne/src/com/codename1/system/NativeLookup.java @@ -72,6 +72,10 @@ public static void setVerbose(boolean aVerbose) { /// /// @return an instance of that interface that can be invoked or null if the native interface isn't /// present on the underlying platform (e.g. simulator platform). + // NativeLookup is itself the sanctioned escape hatch for class-by-name + // resolution; the JavaSE simulator path falls back to a name-derived + // *Impl lookup that the registered map cannot cover. + @SuppressWarnings("BanClassForName") public static T create(Class c) { try { Class cls = interfaceToClassLookup.get(c); diff --git a/CodenameOne/src/com/codename1/testing/DeviceRunner.java b/CodenameOne/src/com/codename1/testing/DeviceRunner.java index fe65c68c0e..40936db3da 100644 --- a/CodenameOne/src/com/codename1/testing/DeviceRunner.java +++ b/CodenameOne/src/com/codename1/testing/DeviceRunner.java @@ -103,6 +103,10 @@ public void runTests() { /// #### Parameters /// /// - `testClassName`: the class name of the test case + // The test runner receives the unit test class as a String (it is the + // entry point invoked from the device-side test bootstrap), so loading + // it by name is the whole point of the method. + @SuppressWarnings("BanClassForName") public void runTest(String testClassName) { try { final UnitTest t = (UnitTest) Class.forName(testClassName).newInstance(); diff --git a/docs/developer-guide/.gitignore b/docs/developer-guide/.gitignore index 43c9348f69..0bdb9709a1 100644 --- a/docs/developer-guide/.gitignore +++ b/docs/developer-guide/.gitignore @@ -1,10 +1,12 @@ book-cover.generated.svg book-cover.generated.png # Vale syncs upstream style packages here, but the project-specific vocabulary -# (config/vocabularies/CodenameOne) is tracked so CI runs see the same word -# list local contributors do. +# (config/vocabularies/CodenameOne) and our locally authored rule directory +# (styles/CodenameOneRules) are tracked so CI runs see the same word list +# and custom checks local contributors do. styles/* !styles/config/ +!styles/CodenameOneRules/ styles/config/* !styles/config/vocabularies/ styles/config/vocabularies/* diff --git a/docs/developer-guide/.vale.ini b/docs/developer-guide/.vale.ini index 350e0d1307..424d27009e 100644 --- a/docs/developer-guide/.vale.ini +++ b/docs/developer-guide/.vale.ini @@ -175,7 +175,7 @@ Packages = https://github.com/errata-ai/packages/releases/download/v0.2.0/Micros # removes the conjunction the reader relies on. [*.{adoc,asciidoc}] -BasedOnStyles = Microsoft, proselint, write-good +BasedOnStyles = Microsoft, proselint, write-good, CodenameOneRules TokenIgnores = (?s)// vale-skip:[^\n]*\n[^\n]+\n write-good.Passive = NO diff --git a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc index aba7c75951..6ad3204325 100644 --- a/docs/developer-guide/The-Components-Of-Codename-One.asciidoc +++ b/docs/developer-guide/The-Components-Of-Codename-One.asciidoc @@ -2565,7 +2565,7 @@ image::img/components-imageviewer-multi.png[An ImageViewer with many elements is Notice that you use a https://www.codenameone.com/javadoc/com/codename1/ui/list/ListModel.html[ListModel] to allow swiping between images. -From Codename One 9.0, `ImageViewer` also supports optional side arrows (material font icons) and an optional thumbnail strip for direct image navigation: +`ImageViewer` also supports optional side arrows (material font icons) and an optional thumbnail strip for direct image navigation: [source,java] ---- diff --git a/docs/developer-guide/styles/CodenameOneRules/NonexistentVersions.yml b/docs/developer-guide/styles/CodenameOneRules/NonexistentVersions.yml new file mode 100644 index 0000000000..abb0be2943 --- /dev/null +++ b/docs/developer-guide/styles/CodenameOneRules/NonexistentVersions.yml @@ -0,0 +1,23 @@ +extends: existence +message: "'%s' refers to a Codename One version that has not been released. The current release line is 7.0.x; do not write 'Codename One 8', 'Codename One 9', or 'Codename One 7.1'." +level: error +ignorecase: false +nonword: true +# `vocab: false` is critical: by default Vale subtracts any term from +# `styles/config/vocabularies/CodenameOne/accept.txt` (which includes +# "Codename") from matches, which would silently swallow every hit because +# our patterns start with "Codename One". +vocab: false +# Match the version number in isolation, then assert "Codename One" precedes it +# in raw text (vale RE2 has no lookbehind, so we use \b on either side and rely +# on the regex matching unique enough strings inside prose). +# +# Block: +# - "Codename One 8"/"Codename One 9"/... (no major version >= 8 has shipped) +# - "Codename One 7.1"/"Codename One 7.2"/... (latest 7.x is 7.0.81; nothing +# past 7.0 has shipped) +# We deliberately allow plain "Codename One 7" and "Codename One 7.0.x" +# because those are the live release line. +tokens: + - '\bCodename One ([89]|[1-9][0-9]+)\b' + - '\bCodename One 7\.[1-9][0-9]*\b' diff --git a/maven/core/pom.xml b/maven/core/pom.xml index 9a0c96f641..a069cc54b1 100644 --- a/maven/core/pom.xml +++ b/maven/core/pom.xml @@ -62,6 +62,81 @@ 1.8 + + + errorprone + + 2.27.1 + + + + + maven-compiler-plugin + + 1.8 + 1.8 + UTF-8 + true + + true + + -XDcompilePolicy=simple + + -Xplugin:ErrorProne -XepDisableAllChecks -Xep:BanClassForName:ERROR + + -J--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + -J--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED + + + + com.google.errorprone + error_prone_core + ${errorprone.version} + + + com.codenameone + errorprone-checks + ${project.version} + + + + + + + diff --git a/maven/errorprone-checks/pom.xml b/maven/errorprone-checks/pom.xml new file mode 100644 index 0000000000..d49630923a --- /dev/null +++ b/maven/errorprone-checks/pom.xml @@ -0,0 +1,89 @@ + + + 4.0.0 + + + com.codenameone + codenameone + 8.0-SNAPSHOT + + + errorprone-checks + jar + codenameone-errorprone-checks + + Custom Error Prone bug checkers that the framework build runs over + CodenameOne/src. Currently enforces: + - BanClassForName: refuses any use of java.lang.Class.forName(...) + Built only on JDK 11+ (the minimum Error Prone supports); the + framework's primary JDK 8 build path skips this module via the + profile activation below. + + + + 11 + 11 + 2.27.1 + + + + + com.google.errorprone + error_prone_check_api + ${errorprone.version} + provided + + + com.google.errorprone + error_prone_annotations + ${errorprone.version} + provided + + + com.google.auto.service + auto-service-annotations + 1.1.1 + provided + + + + + + + maven-compiler-plugin + + ${maven.compiler.source} + ${maven.compiler.target} + + + --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + + + + com.google.auto.service + auto-service + 1.1.1 + + + + + + + diff --git a/maven/errorprone-checks/src/main/java/com/codenameone/errorprone/BanClassForName.java b/maven/errorprone-checks/src/main/java/com/codenameone/errorprone/BanClassForName.java new file mode 100644 index 0000000000..76c3269e55 --- /dev/null +++ b/maven/errorprone-checks/src/main/java/com/codenameone/errorprone/BanClassForName.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + */ +package com.codenameone.errorprone; + +import com.google.auto.service.AutoService; +import com.google.errorprone.BugPattern; +import com.google.errorprone.VisitorState; +import com.google.errorprone.bugpatterns.BugChecker; +import com.google.errorprone.bugpatterns.BugChecker.MethodInvocationTreeMatcher; +import com.google.errorprone.matchers.Description; +import com.google.errorprone.matchers.method.MethodMatchers; +import com.google.errorprone.matchers.method.MethodMatchers.MethodNameMatcher; +import com.sun.source.tree.MethodInvocationTree; + +/** + * Rejects any call to {@code java.lang.Class.forName(...)} inside framework + * sources. The framework's iOS port translates bytecode to C ahead of time + * via ParparVM, which cannot resolve classes named only by string at runtime. + * Reflective Class lookups silently work on Android and JavaSE but fail (or + * dead-strip) on iOS, leaving cross-platform bugs that only surface in app + * store builds. Use {@code com.codename1.system.NativeLookup} or an explicit + * Class literal instead. + */ +@AutoService(BugChecker.class) +@BugPattern( + summary = "Class.forName is forbidden in Codename One framework code; " + + "ParparVM cannot resolve classes by string name at runtime. " + + "Use NativeLookup or a Class literal instead.", + severity = BugPattern.SeverityLevel.ERROR, + linkType = BugPattern.LinkType.NONE) +public final class BanClassForName extends BugChecker + implements MethodInvocationTreeMatcher { + + private static final MethodNameMatcher CLASS_FOR_NAME = + MethodMatchers.staticMethod() + .onClass("java.lang.Class") + .named("forName"); + + @Override + public Description matchMethodInvocation( + MethodInvocationTree tree, VisitorState state) { + if (CLASS_FOR_NAME.matches(tree, state)) { + return describeMatch(tree); + } + return Description.NO_MATCH; + } +} diff --git a/maven/pom.xml b/maven/pom.xml index 0dca49fedd..0ce73f81ea 100644 --- a/maven/pom.xml +++ b/maven/pom.xml @@ -624,5 +624,19 @@ core-unittests + + + errorprone + + errorprone-checks + + diff --git a/scripts/check-since-tags.sh b/scripts/check-since-tags.sh new file mode 100755 index 0000000000..d3e5da291d --- /dev/null +++ b/scripts/check-since-tags.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# +# Scan Java sources under CodenameOne/src for @since javadoc tags whose +# referenced version does NOT correspond to a git tag in this repository. +# +# Background: Claude (and human contributors) sometimes write @since markers +# for releases that never shipped, leaving the API reference claiming a +# feature is available in versions readers cannot install. This check fails +# the build until every @since X.Y.Z matches an existing git tag +# (with or without a leading "v"). +# +# Exit codes: +# 0 - all @since values match a tag +# 1 - one or more @since values do not match any tag +# 2 - misconfiguration (missing dirs, no tags fetched, etc.) +# +# Usage: +# scripts/check-since-tags.sh [source-dir] +# +# When no argument is given the script scans CodenameOne/src relative to +# the repository root. +set -euo pipefail + +SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)" +REPO_ROOT="$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)" +SRC_DIR="${1:-$REPO_ROOT/CodenameOne/src}" + +if [[ ! -d "$SRC_DIR" ]]; then + echo "check-since-tags: source directory not found: $SRC_DIR" >&2 + exit 2 +fi + +if ! command -v git >/dev/null 2>&1; then + echo "check-since-tags: git is not on PATH; cannot enumerate tags." >&2 + exit 2 +fi + +# Build the allowed-versions set from `git tag`, stripping a leading "v" so +# both "v7.0" and "7.0" map to the same allowed value. +# +# We also seed the set with the "next patch" of every X.Y.Z tag because +# contributors necessarily write @since BEFORE the release that ships the +# feature is tagged. Concretely, if the highest 7.0.x tag is 7.0.244, we +# also accept @since 7.0.245 so the upcoming release can be referenced +# without flipping the build red on every PR. We deliberately do NOT +# auto-accept next-minor or next-major bumps (e.g. 7.1 or 8.0) — those +# require an explicit prior tag, because they are also exactly the values +# Claude hallucinates most often. +ALLOWED_TAGS_FILE="$(mktemp -t cn1-since-tags.XXXXXX)" +trap 'rm -f "$ALLOWED_TAGS_FILE"' EXIT + +# Pipe through awk to add next-patch entries, then sort -u. +git -C "$REPO_ROOT" tag --list \ + | sed -E 's/^v//' \ + | awk ' + { print $0 } + # Capture every X.Y.Z (numeric Z) and track the largest Z per X.Y. + /^[0-9]+\.[0-9]+\.[0-9]+$/ { + n = split($0, parts, ".") + prefix = parts[1] "." parts[2] + patch = parts[3] + 0 + if (patch > max_patch[prefix]) { + max_patch[prefix] = patch + } + } + END { + # Emit one extra allowed version per release line: max+1. + for (prefix in max_patch) { + print prefix "." (max_patch[prefix] + 1) + } + } + ' \ + | sort -u > "$ALLOWED_TAGS_FILE" + +if [[ ! -s "$ALLOWED_TAGS_FILE" ]]; then + echo "check-since-tags: no git tags found in $REPO_ROOT." >&2 + echo " Make sure the workspace was cloned with tags (e.g. CI uses" >&2 + echo " actions/checkout with fetch-tags: true)." >&2 + exit 2 +fi + +# Collect every '@since ' occurrence with file:line context. +# We deliberately match in any kind of comment (//, /** */, ///) because the +# tag is meaningful in all three forms. +HITS_FILE="$(mktemp -t cn1-since-hits.XXXXXX)" +trap 'rm -f "$ALLOWED_TAGS_FILE" "$HITS_FILE"' EXIT + +# -E for ERE, -H to print filename, -n for line numbers, -r for recurse. +# The version captures digits.dots optionally followed by letters/dashes +# (e.g. "7.0.245", "3.5", "1.0-RC1"). A trailing period is stripped below. +# +# We deliberately require the @since to be preceded on the same line by a +# javadoc marker (`*` from /** */ blocks or `///` from Markdown javadoc). +# That excludes plain `// @since X.Y` code comments — which appear in +# vendored libraries (e.g. MiG Layout in ui/layouts/mig) where the value +# references the upstream library's changelog, not a Codename One release. +grep -EHnr --include='*.java' \ + '(\*|///).*@since[[:space:]]+[0-9][0-9A-Za-z._-]*' \ + "$SRC_DIR" > "$HITS_FILE" || true + +violations=0 + +while IFS= read -r hit; do + # hit format: path:line:full-line + file="${hit%%:*}" + rest="${hit#*:}" + line="${rest%%:*}" + text="${rest#*:}" + + # Extract every @since version on the line (a line may contain only one, + # but the regex is tolerant of multiple). + while IFS= read -r raw_version; do + [[ -z "$raw_version" ]] && continue + # Strip a trailing dot that is sentence punctuation, e.g. + # "@since 3.5. Added the hint..." + version="${raw_version%.}" + if ! grep -Fxq "$version" "$ALLOWED_TAGS_FILE"; then + printf '%s:%s: @since %s does not match any git tag\n' \ + "$file" "$line" "$version" + violations=$((violations + 1)) + fi + done < <(printf '%s\n' "$text" \ + | grep -oE '@since[[:space:]]+[0-9][0-9A-Za-z._-]*' \ + | sed -E 's/@since[[:space:]]+//') +done < "$HITS_FILE" + +if (( violations > 0 )); then + echo "" >&2 + echo "check-since-tags: $violations @since reference(s) point at versions" >&2 + echo " with no matching git tag. Either fix the @since to a released" >&2 + echo " version, or tag the release before merging." >&2 + exit 1 +fi + +echo "check-since-tags: all @since tags reference released versions."