Skip to content

fix(build)!: ship a Java 8 loadable fat jar and gate it in CI - #204

Merged
bernardladenthin merged 1 commit into
mainfrom
claude/bytecode-version-gate
Sep 3, 2026
Merged

fix(build)!: ship a Java 8 loadable fat jar and gate it in CI#204
bernardladenthin merged 1 commit into
mainfrom
claude/bytecode-version-gate

Conversation

@bernardladenthin

Copy link
Copy Markdown
Owner

Summary

  • The CLI fat jar could not run on the Java 8 it targets. Two of its entries are Java 11 bytecode: 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; 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.
  • Binding → 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 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, because 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. examples/logbackConfiguration.xmlexamples/simplelogger.properties.
  • checker-qualprovided 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 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'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 the capture.
  • CI gate: the cross-repo shared .github/verify-bytecode-version.sh (byte-identical in java-llama.cpp / BitcoinAddressFinder / streambuffer / 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 fixed — three things this exposed, all pre-existing on main

All were invisible to mvn test, because spotbugs:check and spotless:check bind to verify:

  1. 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, right next to the name.
  2. CalibrationReport: two FORMAT_STRING_MANIPULATION (format strings reaching String.format through a parameter — now formatted at the call site where they stay compile-time constants), statics ordered after instance fields, one redundant value.length().
  3. An unsorted import in MojoConfigurationMappingTest.

Test plan

  • Affected unit / integration tests pass locally — reactor mvn verify green: 654 + 39 + 32 tests, spotless + spotbugs + enforcer + javadoc all green
  • PIT at the 100 gate, reactor-wide: 807 / 16 / 62 mutations, all 100% killed (core was 775 before; the CalibrationReport refactor added mutants and all are killed)
  • Fat jar inspected: 0 logback classes, 0 checkerframework classes, simplelogger.properties present; the published srcmorph-cli jar 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-fix net.ladenthin:llama, which is the case it exists for
  • Real java -jar smoke still prints Main#run end. — with slf4j-simple rendering it
  • CI is green on this branch — blocked until net.ladenthin:llama:5.2.0 is published; local verification used a locally installed 5.2.0-SNAPSHOT built from java-llama.cpp's matching branch
  • Docs / CHANGELOG updated — new Java 8 bytecode floor section in CLAUDE.md, Changed + Added entries in CHANGELOG.md, srcmorph-cli/README.md, dependency table and PIT counts refreshed

Related 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

  • I have read CONTRIBUTING.md and CODE_OF_CONDUCT.md
  • My commits follow Conventional Commits
  • No security-sensitive changes

🤖 Generated with Claude Code

https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH


Generated by Claude Code

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 ""))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✓ 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.

Comment thread pom.xml
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✓ 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.

Comment thread pom.xml
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✓ 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"
  • provided scope 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.

Comment thread srcmorph/pom.xml
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✓ Critical and well-justified exclusion. Excluding llama's slf4j-simple addresses three distinct problems:

  1. Library consumers: Would get an unwanted binding, competing with their own
  2. Maven plugin: Would have two providers (simple + maven-slf4j-provider), causing silent log loss
  3. 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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✓ 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.

@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

Code Review Summary

Overview

Excellent 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:

  1. Logging binding: logback-classic → slf4j-simple

    • Minimal surface (6 classes, no config/socket layer)
    • Proper security posture for a fat jar
  2. Compile-only checker-qual: Moving to provided scope (4.2.2)

    • Keeps it on javac's classpath where Nullness Checker needs it
    • Excludes from runtime (jar-with-dependencies filters on scope)
    • The explanation of why 3.55.1 wouldn't work is valuable for future maintainers
  3. Transitive slf4j-simple exclusion from llama

    • Fixes double-binding in Maven plugin (would interfere with maven-slf4j-provider)
    • Fixes test suite (57 ListAppender tests casting SimpleLogger to logback.Logger)
    • Solves both Surefire and PIT (excellent note about PIT's independent classpath)

CI Gating ✓

The new .github/verify-bytecode-version.sh script is well-implemented:

  • Scans BUILT artifacts (not resolved classpaths)
  • Correctly skips module-info.class and META-INF/versions/** (never loaded by classpath JVM)
  • Exits with code 2 on empty scan (prevents false passes)
  • Cross-repo synchronized (byte-identical in java-llama.cpp, BitcoinAddressFinder, streambuffer)
  • Integrated into smoke-fatjar job with clear comments

Pre-existing Bug Fixes ✓

  1. spotbugs-exclude.xml: Updated IMPROPER_UNICODE suppression with note that tensorReadLazyMode → lazyMode
  2. CalibrationReport: Fixed FORMAT_STRING_MANIPULATION by moving format strings to call sites (keeping them compile-time constants)
  3. MojoConfigurationMappingTest: Sorted imports (spotless)

Architecture Quality ✓

  • Assembly descriptor correctly uses src/main/assembly-resources (won't ship in published jar)
  • Dependency convergence maintained across all modules
  • Excellent inline documentation explaining constraints
  • spotbugs-exclude.xml updated with proper rationale

Risk Assessment: LOW

  • No breaking changes
  • All changes are additive or tightening constraints
  • Local verification reported 100% PIT mutation kill rate across all modules
  • CI gate (--max-major 52 verification) will catch future regressions

One Note

The PR description mentions CI is blocked on net.ladenthin:llama:5.2.0 publication. This is appropriate — the 5.2.0 binding breaking changes (removed ModelParameters.enableFlashAttn, renamed setTensorReadLazy) mean the code literally cannot compile against 5.1.0. No mitigation needed; this is correct dependency management.

Recommendation: APPROVE — This is solid, production-ready work with good defensive patterns. 👍

@bernardladenthin
bernardladenthin merged commit 4055f99 into main Sep 3, 2026
31 of 39 checks passed
@bernardladenthin
bernardladenthin deleted the claude/bytecode-version-gate branch September 3, 2026 22:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants