Skip to content

fix(build)!: unbreak main's compile and gate every jar on the Java 8 class-file floor - #412

Merged
bernardladenthin merged 2 commits into
mainfrom
claude/bytecode-version-gate
Sep 3, 2026
Merged

fix(build)!: unbreak main's compile and gate every jar on the Java 8 class-file floor#412
bernardladenthin merged 2 commits into
mainfrom
claude/bytecode-version-gate

Conversation

@bernardladenthin

Copy link
Copy Markdown
Owner

Summary

  • main does not compile right now — merge this first. fix!: ship a Java 8 loadable SLF4J binding and checker-qual #411 pinned the shipped checker-qual to 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 with Could not load type: org.checkerframework.framework.qual.DoesNotUnrefineReceiver. Reproduced both ways locally: main fails, main -Dchecker.qual.version=4.2.2 succeeds. The publish run for fix!: ship a Java 8 loadable SLF4J binding and checker-qual #411 was cancelled 26s in, so nothing caught it.
  • The fix is provided scope, not an older version. 4.2.2 stays on the compile classpath where the checker needs it, and provided is excluded from consumers' graph and from the fat jar (jar-with-dependencies takes scope runtime) — 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 imports org.checkerframework.
  • Adds the cross-repo class-file gate so this class of bug is caught by CI rather than by a person. The shared .github/verify-bytecode-version.sh is byte-identical in java-llama.cpp / BitcoinAddressFinder / streambuffer / srcmorph (checksum in workspace/crossrepostatus.md); --max-major comes 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:

Job Scans Why
package llama/target all 16 classifier jars + the default fat jar, as early as they exist
smoke-fatjar-linux downloaded fatjars/ package-fatjars rewrites those zips; they are what users download

Script shape worth knowing: module-info.class and META-INF/versions/** are skipped unconditionally (a classpath JVM never loads either, so a release 9 module-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's ServiceLoader at startup) and checker-qual 4.x — and both times a person found it, not CI.

Test plan

  • Affected unit / integration tests pass locally — mvn -f llama/pom.xml verify: 1474 tests, 0 failures, spotless + spotbugs + enforcer + javadoc all green
  • Reactor siblings still build — mvn -pl llama-langchain4j,llama-kotlin -am install
  • Fat jar inspected: 0 checkerframework entries, simplelogger.properties present; the published library jar carries neither
  • Gate verified against a real mvn -P assembly package: 1904 classes across the six jars in llama/target, 0 over major 52
  • CI is green on this branch
  • Docs updated — the Java 8 bytecode floor section in CLAUDE.md now explains why the obvious fix is not the fix, and documents the gate

Related 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

  • 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

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
@claude

claude Bot commented Sep 3, 2026

Copy link
Copy Markdown

This PR elegantly solves a real production problem with strong defensive infrastructure. Ready to merge.

Strengths:

  1. Correct root cause - Nullness Checker resolves qualifiers through compile classpath. provided scope is the right fix.
  2. Robust bytecode script - Proper class file validation, efficient single-pass reading, exit code 2 on empty scan, correct JVM semantics.
  3. Defense in depth - Gate runs twice to catch different failure modes.
  4. Excellent documentation - Explains why pinning 3.55.1 doesn't work.

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.

Comment on lines +99 to +101
if not jars:
print(f"::error::no jars found under: {', '.join(paths)} -- refusing to report a pass", file=sys.stderr)
sys.exit(2)

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

Comment on lines +129 to +132
if len(head) < 8 or head[:4] != b"\xca\xfe\xba\xbe":
continue
scanned += 1
major = int.from_bytes(head[6:8], "big")

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

Comment thread llama/pom.xml
<artifactId>checker-qual</artifactId>
<version>${checker.qual.version}</version>
<optional>true</optional>
<version>${checker.version}</version>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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, so provided is 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.

Comment thread CLAUDE.md
Comment on lines +1640 to +1655
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>...

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Documentation is excellent: This section clearly explains:

  1. Why pinning to 3.55.1 doesn't work (processor and qualifiers must match major versions)
  2. Why provided scope is the right solution (compile-time only, excluded from runtime and fat jars)
  3. Why the issue is important (caught twice already—logback 1.4.0+ and checker-qual 4.x)
  4. 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.

@sonarqubecloud

sonarqubecloud Bot commented Sep 3, 2026

Copy link
Copy Markdown

@bernardladenthin
bernardladenthin merged commit d8e2d0b into main Sep 3, 2026
30 of 35 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