fix(build)!: unbreak main's compile and gate every jar on the Java 8 class-file floor - #412
Conversation
main does not compile. #411 pinned the shipped checker-qual to 3.55.1, the last release whose classes are Java 8 bytecode, so that a Java 8 consumer could not hit UnsupportedClassVersionError on annotations Jackson loads reflectively. The reasoning about the artifact was right; the mechanism was wrong. The Nullness Checker resolves its own qualifiers through javac's symbol table — the COMPILE CLASSPATH — not through the annotation-processor path. Under the 4.2.2 processor a 3.x checker-qual is missing org.checkerframework.framework.qual.DoesNotUnrefineReceiver, and every build dies with "Configuration problem! Could not load type: ...". Processor and qualifiers must share a major version. Reproduced both ways locally: main fails, main with -Dchecker.qual.version=4.2.2 succeeds. The publish run for #411 was cancelled 26s in, so nothing caught it. provided scope satisfies both constraints at once instead of trading one for the other. 4.2.2 is on the compile classpath where the checker needs it, and provided is excluded from consumers' transitive graph AND from the fat jar (jar-with-dependencies takes scope runtime) — so no checker-qual class of any version reaches a consumer's JVM, which is strictly better than shipping an old one. <optional>true</optional> could not have done this on its own: that descriptor filters on scope only, which is exactly the trap #411 documented. Safe because no source in this module imports org.checkerframework; the annotations exist for the processor, not for our code. Verified on the built artifacts: 0 checkerframework entries in the fat jar, mvn verify green (1474 tests), and the reactor's langchain4j and kotlin modules still build. The now-redundant checker.qual.version property is removed and the rationale rewritten in place, so the next reader sees why the obvious fix is not the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
`release 8` governs only the code we compile. A dependency built for a newer Java lands in the jar untouched and nothing in a normal build objects; the failure surfaces at a consumer's JVM as UnsupportedClassVersionError, which is the worst possible place to find it. This repo has now hit it twice — logback 1.4.0+, whose LogbackServiceProvider SLF4J's ServiceLoader loads at startup, and checker-qual 4.x, whose @retention(RUNTIME) annotations load on any reflection over an annotated element. Both were found by a person, not by CI. Adds the cross-repo shared `.github/verify-bytecode-version.sh`, kept byte-identical in java-llama.cpp, BitcoinAddressFinder, streambuffer and srcmorph; the checksum lives in workspace/crossrepostatus.md. `--max-major` comes from the workflow so the ceiling sits next to the release it belongs to (52 here, 65 in the Java 21 sibling) instead of being duplicated per repo. It runs twice, because the two places answer different questions: * `package`, over `llama/target` — all 16 classifier jars plus the default fat jar, checked as early as they exist and before anything downstream consumes them. * `smoke-fatjar-linux`, over the downloaded `fatjars/` — package-fatjars rewrites those zips to add the backend native trees and the manifest, and they are the artifacts a user actually downloads from a release. Notes on the script's shape: * `module-info.class` and `META-INF/versions/**` are skipped unconditionally. A classpath JVM never loads either, so a high version there is not a defect and waiving them per-repo would only invite blanket exceptions. * exit 2 on an empty scan. A run that found no jars must never read as a pass — an earlier ad-hoc version of this check reported a clean pass over a directory a failed build had left empty. * paths may be jars or directories, so one invocation covers a whole artifact set rather than needing a step per classifier. Verified locally against a real `mvn -P assembly package`: 1904 classes across the six jars in llama/target, 0 over major 52. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AnNYn8W1xuVxVJtyL34GyH
|
This PR elegantly solves a real production problem with strong defensive infrastructure. Ready to merge. Strengths:
Code Quality: Shell validation solid, Maven scope semantics correct, error reporting proper. Verification: Fixes PR-411 regression, 1474 tests pass, 0 checkerframework in fat jar, 1904 classes scanned with 0 over major 52. |
| if not jars: | ||
| print(f"::error::no jars found under: {', '.join(paths)} -- refusing to report a pass", file=sys.stderr) | ||
| sys.exit(2) |
There was a problem hiding this comment.
Excellent defensive design: Exit code 2 on empty scan prevents a critical failure mode. A failed build step that silently produces no artifacts would otherwise read as a pass. This catches glob-match failures, renamed output directories, and download step regressions.
Per the PR description: the first version of this check reported a clean pass over a directory a failed build had left empty — this guard prevents that exact bug."
| if len(head) < 8 or head[:4] != b"\xca\xfe\xba\xbe": | ||
| continue | ||
| scanned += 1 | ||
| major = int.from_bytes(head[6:8], "big") |
There was a problem hiding this comment.
Correct class file format parsing:
- Bytes 0-3: Magic number 0xCAFEBABE
- Bytes 4-5: Minor version (skipped here)
- Bytes 6-7: Major version (class file format version)
Major version mapping: 52=Java 8, 55=Java 11, 61=Java 17, 65=Java 21. The calculation major - 44 in the error message is correct per the Java spec.
This efficient approach reads only the 8-byte header per class rather than spawning unzip per entry—important for fat jars with thousands of classes.
| <artifactId>checker-qual</artifactId> | ||
| <version>${checker.qual.version}</version> | ||
| <optional>true</optional> | ||
| <version>${checker.version}</version> |
There was a problem hiding this comment.
Scope change is key: provided scope accomplishes what a version pin alone cannot:
- Compile classpath: 4.2.2 is present (so the Nullness Checker finds its qualifiers and the build succeeds)
- Runtime classpath: excluded (so no checker-qual classes reach a consumer's JVM)
- Fat jar: excluded (jar-with-dependencies uses
scope runtime, soprovidedis filtered out)
This is strictly better than shipping an old 3.55.1 copy, and it's the only way to keep both processor and qualifiers on the same major version without breaking consumers.
| outright: the Nullness Checker resolves its own qualifiers through javac's symbol table, i.e. the | ||
| *compile classpath*, so a 3.x checker-qual under the 4.x processor fails every build with | ||
| `Could not load type: org.checkerframework.framework.qual.DoesNotUnrefineReceiver`. Processor and | ||
| qualifiers must share a major version. `provided` satisfies both constraints: 4.2.2 on the compile | ||
| classpath where the checker needs it, and excluded from consumers' graph **and** from the fat jar | ||
| (`jar-with-dependencies` takes scope `runtime`), so no checker-qual class of any version ships. | ||
| `<optional>true</optional>` would not have been enough on its own — that descriptor filters on | ||
| scope only. Safe because no source imports `org.checkerframework`. | ||
|
|
||
| **The gate: `.github/verify-bytecode-version.sh`.** Kept **byte-identical** across java-llama.cpp / | ||
| BitcoinAddressFinder / streambuffer / srcmorph (checksum table in `workspace/crossrepostatus.md`). | ||
| It opens every `.class` in every jar it is given and fails on any whose class-file major version | ||
| exceeds `--max-major`: | ||
|
|
||
| ```bash | ||
| .github/verify-bytecode-version.sh --max-major 52 [--allow '<jar>:<entry>']... <jar-or-dir>... |
There was a problem hiding this comment.
Documentation is excellent: This section clearly explains:
- Why pinning to 3.55.1 doesn't work (processor and qualifiers must match major versions)
- Why
providedscope is the right solution (compile-time only, excluded from runtime and fat jars) - Why the issue is important (caught twice already—logback 1.4.0+ and checker-qual 4.x)
- How the gate prevents future occurrences
This is exactly the kind of documentation that helps prevent the next person from undoing the fix by trying the obvious (but incorrect) alternative.
|



Summary
maindoes not compile right now — merge this first. fix!: ship a Java 8 loadable SLF4J binding and checker-qual #411 pinned the shippedchecker-qualto 3.55.1; the Nullness Checker resolves its own qualifiers through javac's symbol table (the compile classpath), so a 3.x qual under the 4.2.2 processor fails every build withCould not load type: org.checkerframework.framework.qual.DoesNotUnrefineReceiver. Reproduced both ways locally:mainfails,main -Dchecker.qual.version=4.2.2succeeds. The publish run for fix!: ship a Java 8 loadable SLF4J binding and checker-qual #411 was cancelled 26s in, so nothing caught it.providedscope, not an older version. 4.2.2 stays on the compile classpath where the checker needs it, andprovidedis excluded from consumers' graph and from the fat jar (jar-with-dependenciestakes scoperuntime) — so no checker-qual class of any version ships, which is strictly better than shipping an old one.<optional>true</optional>could not have done this alone; that descriptor filters on scope only, exactly the trap fix!: ship a Java 8 loadable SLF4J binding and checker-qual #411 documented. Safe because no source importsorg.checkerframework..github/verify-bytecode-version.shis byte-identical in java-llama.cpp / BitcoinAddressFinder / streambuffer / srcmorph (checksum inworkspace/crossrepostatus.md);--max-majorcomes from the workflow so the ceiling sits next to the release it describes (52 here, 65 in the Java 21 sibling).It runs twice, because the two places answer different questions:
packagellama/targetsmoke-fatjar-linuxfatjars/package-fatjarsrewrites those zips; they are what users downloadScript shape worth knowing:
module-info.classandMETA-INF/versions/**are skipped unconditionally (a classpath JVM never loads either, so arelease 9module-info is fine, and waiving them per-repo would only invite blanket exceptions); exit 2 on an empty scan, because a run that found no jars must never read as a pass; paths may be jars or directories, so one invocation covers a whole artifact set instead of a step per classifier.This repo has now hit the underlying problem twice — logback 1.4.0+ (
LogbackServiceProvider, loaded by SLF4J'sServiceLoaderat startup) and checker-qual 4.x — and both times a person found it, not CI.Test plan
mvn -f llama/pom.xml verify: 1474 tests, 0 failures, spotless + spotbugs + enforcer + javadoc all greenmvn -pl llama-langchain4j,llama-kotlin -am installcheckerframeworkentries,simplelogger.propertiespresent; the published library jar carries neithermvn -P assembly package: 1904 classes across the six jars inllama/target, 0 over major 52Java 8 bytecode floorsection inCLAUDE.mdnow explains why the obvious fix is not the fix, and documents the gateRelated issues / PRs
Fixes the regression introduced by #411. Companion PRs add the same gate to BitcoinAddressFinder, streambuffer and srcmorph, 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