Skip to content

Fix the host gaps found running libraries' own test harnesses - #528

Merged
yogthos merged 32 commits into
mainfrom
fix/library-conformance-sweep
Aug 3, 2026
Merged

Fix the host gaps found running libraries' own test harnesses#528
yogthos merged 32 commits into
mainfrom
fix/library-conformance-sweep

Conversation

@yogthos

@yogthos yogthos commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Replaying third-party suites through each library's real runner instead of the bespoke conformance-run harness surfaced a batch of host bugs the old recipes structurally could not reach — they hand-listed namespaces and supplied their own dep sets, so whole code paths never ran.

The largest single find is the protocol identity bug; everything else is a gap one of the suites walked into.

Protocol identity

Protocol dispatch keyed a method table by the protocol's SIMPLE name, so two protocols named alike in different namespaces shared one table and the later extend silently replaced the earlier one — a wrong answer, not a crash. Dispatch now keys by "<defining-ns>/<Name>", resolved through :refer and :as by a new jolt.host/protocol-key-of. A symbol naming no protocol (Object, java.util.Map, an imported clojure.lang.ILookup) keeps its bare name, because that is how value-host-tags spells the tags a value reports.

This is what was holding malli down: 1,874 → 16,280 assertions in its own suite, ClassCastException 594 → 1. No dispatch cost — dispatch 69.9/66.9/69.9 ms A/B/A.

Host classes and core

  • java.io.File compares by pathname under =. .equals and hash already agreed, so two Files built from one path were unequal only through =, which is how ring-core's resource tests read them. The two-arg constructor also joins with exactly one separator; a parent ending in / produced a//c.
  • (char n) spans the Unicode scalar values. jolt's strings are code-point indexed, so it could not rebuild a char (first s) had just handed out, and data.json errored writing an astral character. This is a deliberate superset of the JVM's 16-bit char — the clojure-test-suite already treats that assertion as host-dependent (jank and basilisp behave as jolt now does), so it lands as a cts baseline line plus a :string-model divergence entry, not as parity.
  • (. Class MEMBER) with no arguments reads a static FIELD when one is registered. It always emitted a static call, so a field's value was applied as a zero-arg procedure and came back nil — a silent wrong answer. (. Class -MEMBER) was rejected outright. Four malli namespaces load-failed on LocalDate/-MIN. (bead jolt-c0ri)
  • Added System/arraycopy, with the JVM's overlapping-copy semantics.
  • A reify declaring IFn is ifn? and an instance of it.
  • Protocol dispatch picks the most specific class; added clojure.datafy, Murmur3/hashCombine, clojure.java.javadoc; fixed cross-namespace FQ-name extend.
  • iconv now issues the POSIX reset call, so a stateful charset emits its closing escape. (.getBytes "い" "ISO-2022-JP") was 5 bytes where the JVM gives 8 — RFC 1468 requires the text to end in ASCII.
  • LineNumberingPushbackReader gets its own jhost tag so it reports its own class. tools.reader does (extend LineNumberingPushbackReader IndexingReader …), which never dispatched.
  • io/resource answers an absolute file: URL. Source roots are relative, and resolving a name against file:./test/x throws MalformedURLException: no protocol.
  • Added Character/codePointAt and Runtime's memory API over Chez's heap accounting.
  • (. Foo bar x) on an imported simple name is a static call, so a method the class token does not answer routes through host-static-ref, where the on-demand autoload lives. (bead jolt-5hnv)

clojure.test reports like reference Clojure

A failure printed one line. The reference prints a blank line, FAIL in (test-name) (file:line), the testing context and message, then expected: / actual: — the shape every editor integration, CI parser and third-party reporter keys off, and which test.check's own suite asserts on directly. Verified byte-identical to 1.12.5 for a failure; an :error differs only in the frames underneath, which tail calls leave nothing to report.

Position comes from the reader — (meta &form) plus *file* — not a stack walk. *report-counters* is incremented too, including :test per test var.

Also fixed here: the registry runner never bound *testing-vars*, so a failure in a registry-run test had an empty name in the header.

deps resolution

  • Accept the sha an annotated tag carries as well as the commit it peels to. git ls-remote prints the tag object for refs/tags/X, so that is what a coordinate written from its output pins (cognitect-labs/test-runner v0.5.0 is one); tools.deps takes either. Verified against the real CLI. The tag cache is now two tokens, and a one-token file from an older jolt is re-resolved rather than trusted.
  • Accept the legacy :sha / :tag spellings alongside the namespaced keys. malli pins spec-alpha2 that way, which blocked its whole test alias.
  • Carry Clojure's spec deps transitively.

stdlib

  • Added clojure.stacktrace, which Clojure ships and jolt did not. Frame lists are empty here (tail calls, already a recorded divergence); the throwable line, ex-data and cause chain match exactly.

Conformance shims

Three suites test against Java libraries jolt has no equivalent for. Each shim lives on a jolt-owned path under test/conformance/libs/shims/ and goes on the recipe — never written into an upstream checkout, where the edit would be invisible once its .git is gone and the recorded tally would silently measure our own source: commons-codec Base64 for data.codec (RFC 4648, as the suite's oracle), commons-lang StringEscapeUtils for markdown-clj, commons-io IOUtils/FileUtils for ring-core.

Also fixed markdown-clj's own finding: an unparseable numeric entity now leaves its token alone.

Effect on library tallies

Passing assertions, and what is left failing. Every figure is a make libconformance measurement, not an estimate.

lib before after
malli 121 pass, 20 load-fail 11,934 pass, 8 load-fail
honeysql 840 pass, 1 load-fail 2,842 pass, 0
rewrite-clj 3,380 pass, 0 fail 3,381 pass, 0 fail
tick 620 pass, 1 load-fail 723 pass, 0
Selmer 525 pass, 1 error 526 pass, 0
ring-core 405 pass, 15 fail 419 pass, 7 fail
test.check 230 pass, 15 fail 236 pass, 10 fail
tools.logging 219 pass, 4 error 226 pass, 0
data.json 320 pass, 2 error 322 pass, 0
markdown-clj 180 pass, 2 error 182 pass, 0
test.chuck 98 pass, 22 fail 110 pass, 17 fail
hiccup 385 pass, 1 fail 386 pass, 0 fail
data.codec 1 pass, 11 error 12 pass, 0

malli's fail/error also rise, because seven namespaces that used to
load-fail now run at all. rewrite-clj was already complete before this branch —
163 tests / 3,381 assertions is the whole suite, the same counts reference
Clojure runs for it, and the one assertion is a real fix rather than newly
reached coverage.

proxy extends a concrete class by delegation

proxy desugared to reify, which has no base, so a proxy over a concrete class answered only the methods its body declared — every inherited method threw — and proxy-super threw unconditionally. Since jolt generates no classes, a proxy now constructs a real base instance from the first super when that names a constructible class, answers what the body declares, and forwards the rest through the full dispatcher. proxy-super calls the base's own implementation. A proxy is an instance of its base and reports the base's class and host tags, so instance?, class and extend-protocol see through it. A super naming an interface has nothing to construct and stays the reify it was.

Delegation is not subclassing in one respect, recorded in known-divergences: the base holds no reference back, so a base method calling an overridden method runs the base's version where the JVM re-enters the override. Overriding a leaf method — the common case — is identical.

Three things had to follow, and two were bugs that predate this work. PrintStream did not exist (the comment beside System/out said setOut was being withheld until proxy-over-a-host-class existed, so setOut/setErr land here too). OutputStreamWriter transcoded the wrapped stream's port, and R6RS transcoded-port closes it, so (.toString baos) after wrapping one failed on a closed port. And println/prn never flushed although *flush-on-newline* existed and defaulted true with nothing reading it, while clojure.core/flush ignored *out* entirely — both now match Clojure, at 1.02–1.07x on a 200k-line println microbenchmark (1350/1413ms → 1444/1442ms).

A URLStreamHandler decides what its URL reads

java.net.URL took only (URL. spec), so (URL. nil spec handler) passed nil as the spec and raised no protocol: . The constructors are now told apart by argument type the way the JVM's overloads are, and a URL built with a handler reads through it whatever its protocol — openConnection is the handler's, openStream is that connection's getInputStream, and slurp/io/reader route the same way.

Two pre-existing bugs alongside: getPath/getFile stripped only a file: prefix, so every other URL reported its whole spec as its path ((.getFile (URL. "http://h/p")) was "http://h/p", the JVM says "/p"); and io/reader on a URL read the spec as a local file path, so a non-file URL failed as a missing file rather than saying the protocol has no input.

An incomplete gate run can no longer read as a pass

make stops at the first failing target, so everything after it never runs, and re-running a hand-picked subset prints per-target success that looks exactly like the whole gate's. That is how a missing host-manifest line reached CI in this branch.

The gate now runs as a sub-make behind a wrapper: a verdict line is printed either way, so the log can never end on some passing target's output — which also covers make test | tail, where the pipe takes tail's exit status. On a complete pass it writes a receipt naming the gate and a hash of every tracked and untracked-but-not-ignored file, and make gate-status answers whether this working tree is covered. -i and -k are refused for gate targets, at parse time, because -i ignores a recipe line's failure including a guard's own.

Documented rather than fixed

ring-core's 12 remaining errors need an RFC 7578 multipart parser — library-sized, and shimming it would have ring's own multipart tests exercising our parser. data.json's 2 remaining failures are the code-point vs UTF-16 string model. tools.namespace's 1 error is java.lang.reflect. lasertag's 12 are the class model. Each is stated in the manifest comment for the library it affects; the model-level ones are in known-divergences.edn.

Coverage

rewrite-clj keeps a test-isolated/ tree that upstream runs as its own kaocha profile, and the isolation is the assertion: it checks a coercion works when rewrite-clj.zip is the ONLY namespace required, so requiring any other test namespace pulls in rewrite-clj.node and it passes for the wrong reason. The harness runs one process per manifest entry, so rewrite-clj-isolated is exactly that isolation. 1/1; rewrite-clj's own 163/3381 is unchanged, and matches what reference Clojure runs for the same suite.

Gates

Corpus rows for every fix that is a single expression, certified against real JVM Clojure 1.12.5. A clojure.stacktrace acceptance test and a clojure.test output-shape regression, both wired into smoke.sh. 8 deps-alias checks.

make test green: selfhost fixpoint holds, corpus 0 new divergences, unit 1226/1226, cts at baseline, smoke 121/0, certify 0 NEW / 0 stale.

Yogthos added 2 commits August 2, 2026 21:40
Replaying third-party suites through each library's real runner (cognitect
test-runner, kaocha's fallbacks, lein's paths) instead of the bespoke
conformance-run harness surfaced a batch of host bugs the old recipes could not
reach, because they hand-listed namespaces and supplied their own dep sets.

deps resolution:
- accept the sha an annotated tag carries as well as the commit it peels to.
  git ls-remote prints the tag object for refs/tags/X, so that is what a
  coordinate written from its output pins (cognitect-labs/test-runner v0.5.0);
  tools.deps takes either. The tag cache is now two tokens and a one-token file
  from an older jolt is re-resolved rather than trusted.
- accept the legacy :sha / :tag spellings alongside :git/sha / :git/tag, which
  tools.deps has always taken. malli pins spec-alpha2 that way.

host classes:
- iconv: issue the POSIX reset call so a stateful charset emits its closing
  escape. (.getBytes "い" "ISO-2022-JP") dropped the trailing ESC ( B.
- give LineNumberingPushbackReader its own jhost tag so it reports its own
  class; tools.reader extends IndexingReader onto that class and the extension
  never dispatched. Every literal tag test now goes through one predicate.
- io/resource answers an absolute file: URL. Source roots are relative, and
  resolving a name against "file:./test/x" throws MalformedURLException.
- add Character/codePointAt, and Runtime's memory API over Chez's own heap
  accounting (totalMemory/freeMemory/maxMemory/gc) plus runFinalization.
- (. Foo bar x) on an imported simple name is a static call, so route a method
  the class token does not answer through host-static-ref — which is where the
  on-demand class autoload lives. The slash form always had it; this form only
  worked when an earlier slash-form call had already loaded the provider.

stdlib:
- add clojure.stacktrace, which Clojure ships and jolt did not. Frame lists are
  empty here (tail calls); the throwable line, ex-data and cause chain match.

diagnostics:
- a provider that is on the source roots but fails to load is no longer reported
  as a dependency the caller forgot to declare.

Gates: 7 corpus rows, an 8-assertion clojure.stacktrace acceptance test wired
into smoke.sh, and 8 deps-alias checks. known-divergences gains the classloader,
URLStreamHandler, JMX and spec.alpha-transitivity entries.
Two more gaps from running libraries' own harnesses.

org.clojure/clojure is intrinsic — jolt IS Clojure, and putting the artifact's
source on the roots would shadow core — but that does not make its DEPENDENCIES
intrinsic. The artifact depends on spec.alpha and core.specs.alpha, neither of
which is part of core on either host, so on the JVM a project declaring only
Clojure still gets clojure.spec.alpha; libraries lean on that and require spec
without naming it (kaocha, spec.alpha's own suite). Dropping the coordinate whole
took its children with it. Substitute them instead, reading the versions off the
declared Clojure's own POM so they match what tools.deps resolves — 1.12.4 gives
spec.alpha 0.5.238, 1.9.0 gives 0.1.143, as on the JVM. A pinned pair covers an
unreadable POM, and that lookup is quiet: it is not degrading to a jar's pom.xml
the way the warning says, since jolt never loads Clojure's jar.

host-new already tried the class autoload, but jt-library-names is hand-
maintained and had drifted from what jolt-lang/time registers.
DateTimeFormatterBuilder was missing, so (java.time.format.DateTimeFormatterBuilder.)
autoloaded while an imported (DateTimeFormatterBuilder.) did not — a
fully-qualified miss reaches the autoload through java-time-prefixed? anyway, so
only the simple form, which is how libraries write it, was affected. It kept
malli.transform from loading.

The grenadine test that asserted Clojure is pruned now asserts what it was
actually protecting: Clojure itself absent, its spec children present.
@yogthos

yogthos commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed two more fixes from continuing the sweep (a7bc78f).

spec.alpha is transitive from org.clojure/clojure. The coordinate is intrinsic — jolt is Clojure, and putting the artifact's source on the roots would shadow core — but that does not make its dependencies intrinsic. spec.alpha and core.specs.alpha are part of core on neither host, so on the JVM a project declaring only Clojure still gets clojure.spec.alpha, and libraries lean on that without ever naming it (kaocha, spec.alpha's own suite). Dropping the coordinate whole took its children with it.

Versions come from the declared Clojure's own POM, so they match what tools.deps resolves:

declared jolt JVM
clojure 1.12.4 spec.alpha 0.5.238, core.specs.alpha 0.4.74 same
clojure 1.9.0 spec.alpha 0.1.143, core.specs.alpha 0.1.24 same

A pinned pair covers an unreadable POM, and that lookup is quiet — it is not degrading to a jar's pom.xml the way the existing warning says, since jolt never loads Clojure's jar.

A library class autoloads by imported simple name. host-new already attempted the autoload, but jt-library-names is hand-maintained and had drifted from what jolt-lang/time registers. DateTimeFormatterBuilder was absent, so the fully-qualified form autoloaded (it reaches the autoload via java-time-prefixed? regardless) while (DateTimeFormatterBuilder.) after an import did not — which is how libraries actually write it. It kept malli.transform from loading.

The grenadine test that asserted "Clojure is pruned" now asserts what it was actually protecting: Clojure itself absent, its spec children present.

make test green, certify clean: 41 of 41 divergences known, 0 NEW, 0 stale. The spec.alpha entry is removed from known-divergences.edn — it is no longer a divergence.

Paired with jolt-lang/time#5, which fills in the builder itself, malli goes from not loading at all to 199 tests / 1865 assertions.

Yogthos added 19 commits August 2, 2026 23:38
…me extend

Four gaps found running orchard, malli, schema and clj-rss through their own
test harnesses.

clojure.lang.Murmur3 and Util/hashCombine are now reachable as interop. The
port already lived in hasheq.ss; this only names it, for a library that folds
several hashes together by hand (malli's regex parser combines a function
hashCode with a position and a register map). All eight values are certified
against 1.12.5.

clojure.java.javadoc and clojure.java.browse were missing from the stdlib, so
orchard could not load at all. browse drops Clojure's java.awt.Desktop and
Swing fallbacks — there is no AWT here — and is the open-url script path alone,
which is what covers macOS and xdg-open in practice anyway.

extend-protocol to a record named by its FULLY-QUALIFIED name, from a third
namespace, filed the impl under a tag no value carries: register-method
prefixed the name with the EXTENDING namespace, but a fully-qualified name is
already the tag format. schema does exactly this and aborted on load.

ClassLoader.getParent answers nil rather than erroring, which terminates the
usual (take-while identity (iterate #(.getParent %) loader)) walk.

Also records an open bug, jolt-ewmt: protocol method tables are keyed by the
protocol's SIMPLE name, so two protocols of the same name in different
namespaces silently share one table and the later extend wins. That is a wrong
answer, not an error, and it accounts for 594 of malli's 783 errors when its
suites run together. test/chez/proto-collision-app pins the current behaviour so
the fix cannot land unnoticed.
Protocol dispatch walks a value's class tags in order, and a throwable, an atom
and a namespace all reported nothing but "Object" — so an extend-protocol to
Throwable or IRef never fired and the Object default won instead. They now
report their own class and its ancestry, which is what (class x) already said
for all three. Atom joins the graph under IRef rather than IDeref directly, as
it does on the JVM via ARef.

clojure.datafy was missing, and clojure.core.protocols declared Datafiable and
Navigable without Clojure's own nil/Object arms, so "default identity" in those
docstrings was a dispatch miss. Both are needed by anything reaching for datafy;
aws-api is what surfaced it. The java.lang.Class arm of datafy is absent — it is
clojure.reflect, which reads bytecode.

test/chez/datafy-test.clj runs unchanged on reference Clojure and prints the
same DATAFY OK there. It is a smoke case rather than corpus rows because a
qualified reference in the same form as its require compiles before the require
runs.
Two protocols with the same name in different namespaces shared one
dispatch table, so the later extend silently replaced the earlier one.
The JVM keys by the generated interface's FQN, so they stay distinct.
Same collision through instance?: a record in one namespace answered
true for a same-named protocol in another.

defprotocol now bakes "<ns>/<Name>" into the protocol value, its method
shims and the devirt registry; deftype/defrecord/reify/extend-type
resolve the protocol symbol (through :refer and :as) and key their impls
by that. A symbol naming no protocol -- Object, java.util.Map, an
:import-ed clojure.lang.ILookup -- keeps its bare name, which is how the
tags a value reports are spelled. instance?/satisfies? compare a key
against a class name as the interface "<ns>.<Name>".

Found via malli: requiring malli.generator-ast evals a renamed copy of
malli.generator, defining a second protocol named Generator extended to
Object, which replaced the real -generator. That one collision accounted
for 594 of malli's 783 errors.

Fixes jolt-ewmt.
Calling one already worked -- jolt-invoke dispatches to the declared
invoke method -- but both predicates read false, so malli's :ifn schema
rejected a value it should accept. Two causes: the IFn instance? arm
decided #f for anything that isn't a procedure/keyword/symbol/coll
instead of letting the reify's own declared interfaces answer, and
jrec-method? only looked at deftypes.

Reference Clojure gives ifn? true, fn? false, instance? true; jolt gives
the same now, fn? included (reify does not implement Fn). Corpus row
certified against 1.12.5.
Plain java.lang.System surface jolt did not have; data.codec's test helper
copies byte-array slices through it. Specified to behave as if the source
range were copied to a temporary, so an overlapping copy within one array
reads pre-copy values -- walk backwards when the ranges overlap forwards.
Raises the JVM's ArrayIndexOutOfBoundsException / ArrayStoreException /
NullPointerException. Corpus rows cover the overlap and all three classes.
data.codec checks its own base64 against org.apache.commons.codec, a JVM jar,
so 11 of its 14 tests died on Base64/encodeBase64 and the suite recorded
pass=1. The shim registers the class with an independent RFC 4648
implementation -- deriving it from data.codec's own code would make the test
tautological -- on a jolt-owned path ahead of the library's sources, so the
checkout stays unmodified.

pass=1 fail=2 error=11 -> pass=12 fail=2 error=0. The two remaining failures
are instance? against clojure.lang.IFn$OLLOL, a JVM primitive-signature
function interface jolt has no model for.
…cipe

Keying protocol dispatch by defining namespace let four suites get further:
rewrite-clj 162/3380 -> 163/3381, hiccup's one failure to none, ring-core
151/405 -> 152/408, and tick 41/620 with a load failure -> 67/723 clean.

honeysql read WORSE for an unrelated reason: honey.sql-alphanumeric-test is a
defspec needing test.check, test.chuck's generators and instaparse, none of
which were on the recipe, so it load-failed while its passing count went up.
With those on :deps the namespace runs -- 175/840 -> 177/2842, no load
failures. Property-based, so it gets a tolerance like the other generative
suites.
The autourl tests unescape markdown's own HTML output through
org.apache.commons.lang.StringEscapeUtils, a JVM jar that does not exist
here. A preloaded shim registers unescapeHtml over named and numeric (decimal
and hex) entities, implemented independently of markdown's escaping. The two
errors collapse to zero; Selmer's suite never references StringEscapeUtils, so
it gets no shim and its single :custom-resource-path URL error stays. Tally
94/180/0/2 -> 94/182/0/0.
parse-radix returned (reduced nil) for a bad digit, but reduced only means
anything to reduce -- inside a loop it is a Reduced object, and a Reduced is
truthy, so the caller took it for a code point and (char x) threw. "&#xZZ;"
now comes back unchanged, which is what commons-lang does with an entity it
cannot parse.
The byte-array multipart store drains uploads through
org.apache.commons.io.IOUtils/toByteArray and the resource-response test
writes a temp file through FileUtils/writeStringToFile, a JVM jar that does
not exist here. A preloaded shim registers both statics: toByteArray is the
stream's readAllBytes, writeStringToFile is spit. Both error tests move: the
byte-array store passes fully, and the resource test gets past FileUtils to
the java.net.URLClassLoader ctor (a separate JDK gap, recorded in the needs
file, not shimmed). Tally 152/408/15/18/2 -> 152/411/15/17/2.
The jolt.time preload does not clear the four malli.experimental.time
namespaces: they reach a static field through (. Class FIELD), which jolt
answers with nil, and (. Class -FIELD), which it rejects outright. That is a
separate gap from the class seams; 12 is the measured number.
The entry still said 8/2/4, measured before arraycopy landed in the java
layer. Measured now: 12 pass, 2 fail, no errors -- the whole suite runs and
only the IFn$O..O primitive-interface checks remain.
Three gaps the library sweep surfaced, each measured against 1.12.5.

java.io.File compared by identity under =, so two Files built from the same
path were unequal -- .equals and hash already agreed. That is five of
ring-core's failures, which read as (not (= #object[java.io.File "x"]
#object[java.io.File "x"])).

The two-arg File constructor joined with a bare "/", so a parent ending in a
separator produced "a//c". All four corners now match the JVM, including an
empty child yielding the parent alone.

(char n) accepted only 0-0xFFFF while jolt's strings are code-point indexed,
so it could not rebuild a char (first s) had just handed out -- and data.json
errored writing an astral character. The range is now the Unicode scalar
values, a deliberate superset of the JVM's 16-bit char; surrogates stay
rejected because they are not scalar values. Two corpus rows pinned the old
limit: the checked-narrow probe moves to a value out of range on both sides,
and the astral round trip moves to unit.edn with a known-divergences entry,
since the JVM throws there.
ring-core 411 pass / 15 fail -> 419 / 7: File equality by pathname settles
the resource and file-response body comparisons. data.json 320 pass / 2 error
-> 322 / 0: the :escape-unicode false path no longer throws out of (char cp).

Both residues are now stated as what they are -- ring-core's on
commons-fileupload and JDK classloader surface, data.json's purely the
codepoint string model.
A failure printed "FAIL: (= 1 2) expected: ... actual: ..." on one line; the
reference prints a blank line, "FAIL in (test-name) (file:line)", the testing
context and message, then expected:/  actual: with the two-space alignment.
Every editor integration, CI parser and third-party reporter keys off that
shape, and test.check's own suite asserts on it directly.

The position comes from the reader rather than a stack walk: `is` stashes
(meta &form) plus *file* in *report-pos* and do-report merges it into :fail
and :error maps. Verified byte-identical to 1.12.5 for a failure; an :error
differs only in the frames underneath, which tail calls leave nothing to
report -- already documented.

*report-counters* is now incremented, including :test per test var, so a suite
that binds it and reads it back sees {:test 1 :pass 0 :fail 0 :error 1} as on
the JVM. bump-counters! moves that ref and inc-pass!/fail!/err! move jolt's
process-wide atom; keeping them separate is what stops a count landing twice,
and inc-report-counter keeps its existing contract of moving the atom when no
ref is bound.

Also: the registry runner never bound *testing-vars*, so a failure in a test
run through it had an empty name in the header; and both crash paths still
passed err! a string after it started taking a report map.
clojure.core-test.char's "4+ byte characters" assertion is host-dependent in
the suite itself: it expects a throw on the JVM, but the character back on
jank and basilisp, which index strings by code point as jolt does. jolt has
no branch there so it takes :default and fails -- (= (first "\u{10127}")
(char 65895)) holds here exactly as the :lpy branch asserts.
test.check 230 pass / 15 fail / 3 error -> 236 / 10 / 2, test.chuck 98 / 22 / 3
-> 110 / 17 / 2. Both suites assert on the "FAIL in (name) (file:line)" header
and on *report-counters*, which is what the reference-shaped reporter now
gives them.

Also fixes what the first measurement caught: run-one bound *testing-vars* to
a stand-in map, and test.check's reporter reads that stack expecting vars --
56 errors' worth of "cannot be cast to Named". It resolves the real var now,
and leaves the stack alone when a test's var no longer resolves.
(. Class MEMBER) with no arguments always emitted a static CALL, so a static
field's value was applied as a zero-arg procedure and came back nil -- a wrong
answer, not an error. (. Class -MEMBER), the explicit field spelling, was
rejected outright.

The no-argument form is ambiguous on the JVM too: a static field if one
exists, else a no-arg static method. jolt keeps one registry for both, so
jolt.host/static-member decides at runtime on what is registered -- a
procedure is a method to call, anything else is a field value. The dash
spelling is unambiguous and reads the value directly. With arguments nothing
changes.

Four malli.experimental.time namespaces load-failed on LocalDate/-MIN. Corpus
row covers both field spellings and both call forms, so the fix cannot regress
the calls. Adjacent to jolt-5hnv, which fixed the static METHOD case in July;
the field case survived it. Fixes jolt-c0ri.
load-fail 12 -> 8: the four malli.experimental.time namespaces blocked on
LocalDate/-MIN now load. tests 202 -> 209, pass 11847 -> 11934; fail and error
rise because those namespaces run at all now. What is left is fipp reaching
Clojure's private mk-am (7) and jsonista (1), both JVM-internal.

Note for the record: an earlier expectation in this file guessed load-fail 8
and attributed it to the jolt.time preload. The number was right and the
reason was wrong -- the preload does nothing here; the dot form was the
blocker.
@yogthos yogthos changed the title Fix eleven host gaps found running libraries' own test harnesses Fix the host gaps found running libraries' own test harnesses Aug 3, 2026
Yogthos added 7 commits August 3, 2026 10:43
proxy desugared to reify, which has no base, so a proxy over a concrete class
answered only the methods its body declared -- every inherited method threw --
and proxy-super threw unconditionally. clojure.tools.logging's log-stream needs
both: it proxies ByteArrayOutputStream, overrides flush, and calls proxy-super
flush and reset from inside it.

jolt generates no classes, so a proxy now EXTENDS BY DELEGATION: make-proxy
constructs a real base instance from the first super when that names a
constructible class, answers what the body declares, and forwards the rest to
that instance through the full dispatcher. proxy-super calls the base's own
implementation. A proxy is an instance of its base and reports the base's class
and host tags, so instance?, class and extend-protocol all see through it. A
super naming an interface has nothing to construct and stays exactly the reify
it was.

Delegation is not subclassing in one respect: the base holds no reference back,
so a base method calling an overridden method runs the base's version where the
JVM re-enters the override. Recorded in known-divergences with the return-value
one reify already had.

Three things had to follow for the whole path to work:

PrintStream, which was absent -- the comment where System/out lives said setOut
was being withheld until proxy-over-a-host-class existed, so setOut and setErr
land here too, in mutable static cells.

OutputStreamWriter now encodes through a port that hands each block to the
stream's own write, and its flush reaches the stream underneath. It used to
transcode the stream's port directly, which under R6RS CLOSES that port -- so
(.toString baos) after wrapping one failed on a closed port. That was broken
before this change.

println and prn flush when *flush-on-newline*, as on the JVM. The var existed
and defaulted true but nothing read it, so text sat in a writer's buffer and a
println through an OutputStreamWriter never reached the stream. clojure.core/
flush also only flushed the Chez port, ignoring *out*; it now dispatches to the
writer *out* holds, mirroring how jolt-write already routed a write. Costs
1.02-1.07x on a 200k-line println microbenchmark (1350/1413ms -> 1444/1442ms).

tools.logging 219 pass / 4 error -> 226 / 0. The unit row pinning proxy-super's
old throw moves to the corpus, since jolt and 1.12.5 now agree on it.
A new jolt.host def-var! needs its manifest line; this one was missing, so
manifestcheck failed on CI. My local make test had aborted at unit before
reaching that target, and the targets I re-ran afterwards did not include it.
make stops at the first failing target, so everything after it never runs.
Re-running a hand-picked subset afterwards prints per-target success that looks
exactly like the whole gate's, and that is how the missing host-manifest line
reached CI: the gate had aborted at unit, and the targets re-run afterwards did
not include manifestcheck.

The gate now runs as a sub-make behind a wrapper, so a verdict line is printed
either way and the log can never end on some passing target's output -- which
also covers `make test | tail`, where the pipe takes tail's exit status. The
exit code is preserved.

On a complete pass the wrapper writes target/gate-receipt naming the gate and a
hash of every tracked and untracked-but-not-ignored file. `make gate-status`
answers whether THIS working tree is covered by a full run: a subset run leaves
no receipt, and any edit since changes the hash. That turns "is this gated?"
from something to remember into something to ask.

-i and -k are refused for gate targets, at parse time rather than in the recipe:
-i ignores a recipe line's failure including a guard's own, so a guard inside
the recipe is itself ignored and the gate runs anyway. Confirmed both ways.

The target list moved into CI-GATES/TEST-GATES so the banner, the receipt and
the status check cannot drift from what actually ran.
Without the + prefix the sub-make runs -j1, so CI's -j$(nproc) silently
serialized: 13m28s against about 7m before. make warns about it
('jobserver unavailable: using -j1') but the gate still passes, so only the
clock shows it.
The + line's comment sat inside the recipe, so make echoed it into the gate
log twice per run. Moved above the define.

Handing the jobserver down also handed it to devboot-smoke.sh, which invokes
make itself and cannot claim it, so it warned twice. MAKEFLAGS is cleared for
that recipe; the inner make was serial before this either way.
java.net.URL took only (URL. spec), so (URL. nil spec handler) passed nil as
the spec and raised "no protocol: ". The constructors are now told apart by
argument type the way the JVM's overloads are -- (URL. spec), (URL. context
spec [handler]), (URL. protocol host file) -- and a relative spec resolves
against the context.

A URL built with a handler reads through it whatever its protocol:
openConnection is the handler's, openStream is that connection's
getInputStream, and url-content routes slurp and io/reader the same way. That
is how a caller serves content from a URL space jolt has no protocol for --
Selmer's :url-stream-handler option, which needs the proxy-over-concrete-class
support to express the handler at all.

Two bugs fixed alongside, both pre-existing:

getPath/getFile returned the spec minus a "file:" prefix, so every other URL
reported its whole spec as its path -- (.getFile (URL. "http://h/p")) was
"http://h/p" where the JVM says "/p". A unit row asserted the wrong value; it
moves to the corpus now that jolt agrees with 1.12.5. getHost was absent.

io/reader on a URL read the spec as a local file path, so a non-file URL failed
as a missing file rather than saying the protocol has no input.

Selmer 525/1 error -> 526/0: its suite now passes completely.
Upstream keeps test-isolated/ as its own kaocha profile because the isolation
is the assertion: the test checks a coercion works when rewrite-clj.zip is the
only namespace required, so requiring any other test namespace pulls in
rewrite-clj.node and it passes for the wrong reason. The harness runs one
process per manifest entry, so a second entry over the same checkout is exactly
that isolation. 1/1, and the main entry is unchanged at 163/3381.
Yogthos added 4 commits August 3, 2026 14:35
malli's regex engine allocates through (Array/newInstance Object capacity),
where its own :bb and :cljs branches write (object-array capacity) -- so this
is array allocation with a concrete component type, not the reflection API
tools.namespace needs. newInstance/getLength/get/set over jolt's arrays, with
the JVM's ArrayIndexOutOfBounds and IllegalArgumentException. Like make-array,
the component type selects nothing: jolt's arrays are object-kinded unless
built by a typed constructor.

Void was the one primitive class token missing of the nine. sci maps every
primitive name to its token in one map literal and stopped at the first absent
one, so the whole of sci.core failed to load on that alone.

malli 11934 pass / 134 error -> 12059 / 76: the 58 "Unknown class Array" errors
are gone.

Its residue is characterised in the manifest now rather than left as a number.
72 errors are sci, which does not fully load here for reasons beyond Void: it
reaches PRIVATE clojure.core internals by var (imap-cons, then system-newline)
and pulls further deps behind them. Putting jolt's vendored sci on malli's path
does not move the count, so it stays off. 104 of the 149 failures are
parser-info-test alone, which round-trips values drawn by (mg/sample s {:seed
0}); a fixed seed draws different values here than on the JVM, so the test runs
over different data. :every parsing and registry references agree with 1.12.5
exactly on literal inputs -- checked directly rather than assumed.
The URL divergence entry said jolt has no URLStreamHandler dispatch. It was
written before the handler support landed and is now simply false, so it goes.
certify does not catch this: a :documented entry is prose, not a row it checks.

println flushing on newline, and OutputStreamWriter.flush reaching the stream
beneath it, had no regression at all -- only a row reading *flush-on-newline*'s
default value, which says nothing about the behaviour. Both are load-bearing
for tools.logging, and libconformance is not part of make test, so reverting
either would have passed the gate silently. One row now pins the contract: the
first write lands, the second does not until the explicit flush.
ring's multipart middleware is written directly against commons-fileupload2:
it proxies AbstractFileUpload, reifies RequestContext and ProgressListener,
iterates FileItemInput and catches FileUploadException. None of that is
parsing. jolt.shim.commons-fileupload registers exactly that class surface over
jolt-lang/multipart's RFC 7578 parser, so ring's own code paths stay its own --
the same relationship commons-fileupload has to ring on the JVM, which is what
makes running the suite worth anything. The shim is glue; it decides nothing
about multipart syntax.

Expressing it needs the proxy-over-a-concrete-class support from earlier in
this branch: (proxy [AbstractFileUpload] []) has to reach a real base instance
or .setMaxFileSize has nothing to call.

One general jolt gap came out of it. A deftype or reify declaring
java.lang.Iterable or java.util.Iterator is now seqable, as on the JVM: ring
hands `sequence` its item iterator wrapped in (reify Iterable (iterator [_]
...)), which failed "Don't know how to create ISeq from: ...$reify__0". The
walk is lazy, one element per forced cell, since an iterator is a cursor over
something being produced. Seqable wins over Iterable for a type declaring both,
matching RT.seqFrom -- without that check these arms would have shadowed the
existing coll-interface arm, because arms are consulted newest-first.

ring-core 419 pass / 7 fail / 17 error -> 446 / 5 / 5. Every multipart test
passes; what is left is classloader and jar: URL surface, already recorded.
The fingerprint concatenated file contents in `git ls-files -c -o` order, which
groups untracked separately — so a file merely going from untracked to tracked
reordered the input and changed the hash. Committing, which changes nothing,
therefore read as "not gated", the opposite of what the receipt is for.

Now it hashes each file's name and content over a sorted list, and folds in
submodule status so a submodule bump invalidates it too (the submodule paths
list as directories the per-file hash cannot read).
@yogthos
yogthos merged commit 3e68b9c into main Aug 3, 2026
2 checks passed
@yogthos
yogthos deleted the fix/library-conformance-sweep branch August 3, 2026 20:44
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.

1 participant