fix(build)!: ship a Java 8 loadable fat jar and gate it in CI - #204
Conversation
The CLI fat jar could not run on the Java 8 it claims to target. Two of its
entries are Java 11 bytecode (class-file major 55):
* logback-classic, from 1.4.0 on. SLF4J's ServiceLoader finds
LogbackServiceProvider at startup, so the JVM throws
UnsupportedClassVersionError before a single line is logged. Not a latent
risk — a guaranteed crash. Downgrading is not an option either: the Java 8
logback line (1.3.x) is end-of-life and every CVE since is fixed only in
1.5.x/1.6.x with no backport.
* checker-qual, from 4.0.0 on. Its annotations are @retention(RUNTIME), so
anything reflecting over an annotated element loads them — and srcmorph-cli
binds its entire configuration with Jackson.
Changes:
* srcmorph-cli's runtime binding becomes org.slf4j:slf4j-simple — six classes
from the same release train as slf4j-api, with no configuration or socket
layer for a CVE to live in. Defaults (INFO, stdout, timestamps) live in
src/main/assembly-resources/simplelogger.properties, added by the new
src/assembly/fat-jar.xml. That descriptor is the predefined
jar-with-dependencies verbatim plus that one fileSet: the file must not sit
in src/main/resources or it would ship in the published jar and hijack an
embedder's own slf4j-simple config. Same shape as java-llama.cpp's.
* checker-qual moves to provided scope at 4.2.2. Pinning the shipped copy to
the last Java 8 line (3.55.1) is the obvious fix and does NOT work: the
Nullness Checker resolves its own qualifiers through javac's symbol table,
i.e. the compile classpath, so a 3.x qual under the 4.x processor fails
every build with "Could not load type: ...DoesNotUnrefineReceiver".
provided keeps 4.2.2 where the checker needs it and ships none of it —
jar-with-dependencies filters on scope, which is also why
<optional>true</optional> alone would not have been enough.
* net.ladenthin:llama's own slf4j-simple is excluded transitively. It declares
it for its own fat jar, and runtime scope is transitive, so without this the
library forces a binding on embedders and the Maven plugin gets a second
provider alongside Maven's maven-slf4j-provider. It also keeps the test
classpath single-binding: with two providers the 57 ListAppender tests die
casting SimpleLogger to logback's Logger. A Surefire classpathDependencyExcludes
would have fixed only Surefire — PIT builds its own classpath and ignores it,
so the mutation gate would still fail on a green suite. Excluding at the
source fixes both. logback stays, test-scope, for that capture.
* examples/logbackConfiguration.xml -> examples/simplelogger.properties.
CI gate: the cross-repo shared .github/verify-bytecode-version.sh, byte-identical
in java-llama.cpp, BitcoinAddressFinder, streambuffer and srcmorph (checksum in
workspace/crossrepostatus.md), runs in smoke-fatjar with --max-major 52. It
skips only module-info.class and META-INF/versions/**, which a classpath JVM
never loads, and exits 2 on an empty scan so a build that produced no jars can
never read as a pass.
Also fixes three things this exposed, all pre-existing on main and all invisible
to `mvn test` because spotbugs:check and spotless:check bind to verify:
* the IMPROPER_UNICODE suppression still named tensorReadLazyMode, renamed to
lazyMode a while back — the same stale-FQN class as the PIT targetClasses and
OSInfo repairs. The entry now says so, next to the name.
* CalibrationReport: format strings reaching String.format through a parameter
(FORMAT_STRING_MANIPULATION x2) — now formatted at the call site, where they
stay compile-time constants; statics ordered before instance fields; one
redundant value.length().
* an unsorted import in MojoConfigurationMappingTest (spotless).
Verified locally end to end: reactor verify green (654 + 39 + 32 tests), PIT
807/16/62 all at 100%, the fat jar carries 0 logback and 0 checkerframework
classes, the published library jar carries no simplelogger.properties, the gate
passes over srcmorph-cli/target, and the real `java -jar` smoke still prints
"Main#run end." — with slf4j-simple rendering it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
| print(f"scanned {scanned} class file(s) in {len(jars)} jar(s); " | ||
| f"{len(by_jar)} jar(s) over major {max_major}" | ||
| + (f"; {waived} entr(y/ies) waived by --allow" if waived else "")) | ||
|
|
There was a problem hiding this comment.
Excellent implementation. The bytecode version verification is properly scoped:
- Skips module-info.class and META-INF/versions/** (classpath JVM never loads these)
- Exits with code 2 on empty scan (prevents false positives)
- Clear error messages showing the highest offending major version
- Cross-repo synchronized script is a good pattern for consistency
Minor note: The python3 requirement check on line 74 is good defensive practice.
| appendJsonNumber(out, "decodeTokensPerSecond", FORMAT_TOKENS_PER_SECOND, m.decodeTokensPerSecond()); | ||
| appendJsonNumber(out, "charsPerToken", FORMAT_CHARS_PER_TOKEN, m.charsPerToken()); | ||
| appendJsonNumber(out, "midPrefillTokensPerSecond", FORMAT_TOKENS_PER_SECOND, m.midPrefillTokensPerSecond()); | ||
| appendJsonNumber(out, "loadSeconds", String.format(Locale.ROOT, FORMAT_LOAD_SECONDS, m.loadSeconds())); |
There was a problem hiding this comment.
✓ Excellent fix for FORMAT_STRING_MANIPULATION. Moving format strings to call sites (lines 100-114 in renderJson and similar in renderYaml) keeps them as compile-time constants, which SpotBugs requires.
The Javadoc comment on lines 167-171 correctly explains why this pattern is necessary and prevents future refactoring errors.
| release train as slf4j-api, all major 52, with no configuration or socket layer. | ||
| Pinned here so the direct declaration in srcmorph-cli and whatever | ||
| net.ladenthin:llama brings transitively stay converged for maven-enforcer. | ||
| .github/verify-bytecode-version.sh enforces the class-file floor on the built jars. |
There was a problem hiding this comment.
✓ Excellent rationale and implementation for slf4j-simple pinning:
- Correctly identifies logback 1.3.x (Java 8 EOL) has no CVE backports
- slf4j-simple is minimal (6 classes, no config/socket layer) with solid security posture
- Pinning in parent's dependencyManagement ensures convergence across all modules
- The comment explaining the why (UnsupportedClassVersionError before app startup) will prevent future mistakes
The test-scope logback pin for ListAppender capture is the right tradeoff.
| the fat jar (jar-with-dependencies takes scope runtime), so no checker-qual class of | ||
| any version reaches a consumer's JVM. <optional>true</optional> alone would not have | ||
| been enough — that descriptor filters on scope only. Safe because no source in this | ||
| reactor imports org.checkerframework. |
There was a problem hiding this comment.
✓ Excellent solution for checker-qual Java 8 compatibility. The explanation clarifies why pinning to 3.55.1 (last Java 8 version) fails:
- The Nullness Checker resolves qualifiers through javac's symbol table (compile classpath)
- A 3.x qual under a 4.x processor → "Could not load type: DoesNotUnrefineReceiver"
providedscope is the key: keeps 4.2.2 on compile classpath where javac needs it, excludes from fat jar
This is a subtle constraint that will save future maintainers time.
| with a second provider present those 57 tests die with "class | ||
| org.slf4j.simple.SimpleLogger cannot be cast to class | ||
| ch.qos.logback.classic.Logger". A Surefire classpathDependencyExcludes would fix | ||
| only Surefire: PIT builds its own classpath and ignores it, so the mutation gate |
There was a problem hiding this comment.
✓ Critical and well-justified exclusion. Excluding llama's slf4j-simple addresses three distinct problems:
- Library consumers: Would get an unwanted binding, competing with their own
- Maven plugin: Would have two providers (simple + maven-slf4j-provider), causing silent log loss
- Test suite: The 57 ListAppender tests would fail casting SimpleLogger → logback.Logger
The note about PIT building its own classpath (ignoring Surefire classpathDependencyExcludes) is crucial — excluding at the source fixes both Surefire and PIT mutation tests.
| inside maven-assembly-plugin: outputDirectory /, useProjectArtifact true, | ||
| unpack true, scope runtime. Keep it that way; the only reason this file exists at | ||
| all is the fileSet underneath it. | ||
|
|
There was a problem hiding this comment.
✓ Correct design: simplelogger.properties in src/main/assembly-resources rather than src/main/resources.
If it were in src/main/resources:
- It would ship in the published srcmorph-cli library jar
- Embedders with their own slf4j-simple config would have it silently hijacked
- Fat jar (assembly scope=runtime) filters by scope, not location
This approach ensures defaults only apply to the runnable artifact. Good precedent match with java-llama.cpp.
Code Review SummaryOverviewExcellent work addressing a critical Java 8 compatibility issue in the CLI fat jar. The fix is thorough, well-documented, and introduces proper CI gating to prevent regressions. Critical Fix ✓The CLI fat jar contained Java 11 bytecode (logback-classic from 1.4.0+, checker-qual from 4.0.0+) that would crash on Java 8 with UnsupportedClassVersionError. This PR correctly addresses it:
CI Gating ✓The new
Pre-existing Bug Fixes ✓
Architecture Quality ✓
Risk Assessment: LOW
One NoteThe PR description mentions CI is blocked on Recommendation: APPROVE — This is solid, production-ready work with good defensive patterns. 👍 |
Summary
ServiceLoaderfindsLogbackServiceProviderat startup, so the JVM throwsUnsupportedClassVersionErrorbefore a single line is logged; a guaranteed crash, not a latent risk) and checker-qual (from 4.0.0 on — its annotations are@Retention(RUNTIME), and this CLI binds its whole configuration with Jackson). Downgrading logback is not an option either: the Java 8 line (1.3.x) is EOL and every CVE since is fixed only in 1.5.x/1.6.x with no backport.slf4j-simple, six classes from the same release train asslf4j-api, with no configuration or socket layer for a CVE to live in. Defaults live insrc/main/assembly-resources/simplelogger.properties, added by the newsrc/assembly/fat-jar.xml— that descriptor is the predefinedjar-with-dependenciesverbatim plus that onefileSet, because the file must not sit insrc/main/resourcesor it would ship in the published jar and hijack an embedder's own slf4j-simple config.examples/logbackConfiguration.xml→examples/simplelogger.properties.checker-qual→providedscope at 4.2.2. Pinning the shipped copy to the last Java 8 line (3.55.1) is the obvious fix and does not work: the Nullness Checker resolves its own qualifiers through javac's symbol table, i.e. the compile classpath, so a 3.x qual under the 4.x processor fails every build withCould not load type: …DoesNotUnrefineReceiver.providedkeeps 4.2.2 where the checker needs it and ships none of it —jar-with-dependenciesfilters on scope, which is also why<optional>true</optional>alone would not have been enough.net.ladenthin:llama's own binding is excluded transitively. It declares slf4j-simple for its own fat jar and runtime scope is transitive, so without this the library forces a binding on embedders and the Maven plugin ends up with a second provider alongside Maven'smaven-slf4j-provider. It also keeps the test classpath single-binding: with two providers the 57ListAppendertests die castingSimpleLoggerto logback'sLogger. A SurefireclasspathDependencyExcludeswould have fixed only Surefire — PIT builds its own classpath and ignores it, so the mutation gate would still fail on a green suite. Excluding at the source fixes both; logback stays, test-scope, for the capture..github/verify-bytecode-version.sh(byte-identical in java-llama.cpp / BitcoinAddressFinder / streambuffer / srcmorph, checksum inworkspace/crossrepostatus.md) runs insmoke-fatjarwith--max-major 52. It skips onlymodule-info.classandMETA-INF/versions/**, which a classpath JVM never loads, and exits 2 on an empty scan so a build that produced no jars can never read as a pass.Also fixed — three things this exposed, all pre-existing on
mainAll were invisible to
mvn test, becausespotbugs:checkandspotless:checkbind toverify:IMPROPER_UNICODEsuppression still namedtensorReadLazyMode, renamed tolazyModea while back — the same stale-FQN class as the PITtargetClassesandOSInforepairs. The entry now says so, right next to the name.CalibrationReport: twoFORMAT_STRING_MANIPULATION(format strings reachingString.formatthrough a parameter — now formatted at the call site where they stay compile-time constants), statics ordered after instance fields, one redundantvalue.length().MojoConfigurationMappingTest.Test plan
mvn verifygreen: 654 + 39 + 32 tests, spotless + spotbugs + enforcer + javadoc all greenCalibrationReportrefactor added mutants and all are killed)simplelogger.propertiespresent; the publishedsrcmorph-clijar carries none of it.github/verify-bytecode-version.sh --max-major 52 srcmorph-cli/target→ clean; and it correctly went red against a build using the pre-fixnet.ladenthin:llama, which is the case it exists forjava -jarsmoke still printsMain#run end.— with slf4j-simple rendering itnet.ladenthin:llama:5.2.0is published; local verification used a locally installed 5.2.0-SNAPSHOT built from java-llama.cpp's matching branchJava 8 bytecode floorsection inCLAUDE.md,Changed+Addedentries inCHANGELOG.md,srcmorph-cli/README.md, dependency table and PIT counts refreshedRelated issues / PRs
Companion PRs add the same gate to java-llama.cpp (which also carries the matching
checker-qual/binding fix this depends on), BitcoinAddressFinder and streambuffer, and record it in the workspace checksum table.Checklist
CONTRIBUTING.mdandCODE_OF_CONDUCT.md🤖 Generated with Claude Code
https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
Generated by Claude Code