Background
homebrew-coveralls#71 reported brew install coveralls failing during the source-build path on macOS with Crystal 1.20.0. The compiler error originates in Crystal stdlib, not in our code:
In /usr/local/Cellar/crystal/1.20.0/share/crystal/src/json/to_json.cr:22:15
22 | to_json(json)
^---
Error: expected argument #1 to 'HTTP::Headers#to_json' to be IO, not JSON::Builder
Upstream Crystal issue: crystal-lang/crystal#16886. Crystal maintainer @straight-shoota's response is the load-bearing fact:
HTTP::Headers#to_json(JSON::Builder) was never documented. It just happened to work somewhat accidentally (certainly not fully intentional). We can easily restore this behaviour by adding explicit forwarders for serialization methods.
Crystal 1.20.1 (released 2026-04-29) restored the behavior via explicit forwarders, so the surface symptom is patched in the latest Crystal. However, coverage-reporter is still relying on accidental, undocumented stdlib behavior that the Crystal team has signaled they consider unintentional. A future Crystal minor could remove it again, regressing us silently.
Root cause in this repo
There is exactly one call site in coverage-reporter where to_json / to_pretty_json is invoked on an HTTP::Headers value:
src/coverage_reporter/api/jobs.cr:51:
Log.debug ""---\n⛑ Debug Headers:\n#{headers.to_pretty_json}""
headers is a local HTTP::Headers constructed from DEFAULT_HEADERS.dup + a few merges (lines 42–46). The data payload that gets sent to Coveralls (built in build_request and serialized at jobs.cr:58 and webhook.cr:41) is a plain Hash — no HTTP::Headers inside. The trigger is purely the debug-log formatting.
Object#to_pretty_json expands roughly to:
String.build { |io| JSON.build(io, indent: 2) { |json| to_json(json) } }
…which calls to_json(json_builder) on headers. In Crystal 1.20.0 that overload was missing on HTTP::Headers; in 1.20.1 it's restored as an undocumented forwarder.
Proposed fix
Convert HTTP::Headers to a plain Hash before serializing, so we go through documented JSON paths only:
# before
Log.debug ""---\n⛑ Debug Headers:\n#{headers.to_pretty_json}""
# after
Log.debug ""---\n⛑ Debug Headers:\n#{headers.to_a.to_h.to_pretty_json}""
(headers.inspect is also acceptable if a JSON-shape isn't required for the debug output.)
Acceptance criteria
Suggested workflow for the next Claude Code session
This repo has the superpowers plugin globally available. Recommended sequence:
superpowers:systematic-debugging — root cause is already established above; you do not need to re-investigate. Use the trace as your Phase-1 finding.
superpowers:writing-plans — turn the acceptance criteria into a concrete plan: single-file source change + one regression spec + version bump in shard.yml and src/coverage_reporter.cr + tag + release.
superpowers:test-driven-development — write the failing regression spec first, then change line 51, then watch it go green.
superpowers:verification-before-completion — run make test and make build before claiming done; smoke-test the binary against a 500 response with --debug enabled.
superpowers:finishing-a-development-branch — open a PR; after merge, run make new_version (see README.md) to tag 0.6.18.
- After release: comment on homebrew-coveralls#71 confirming the durable fix shipped and recommend
brew update && brew upgrade coveralls.
Out of scope
- Pinning Crystal in
shard.yml or in the Homebrew formula. There is no crystal@<X> formula in homebrew-core, so a clean pin isn't available; and shard.yml constraints don't influence Homebrew's choice of compiler — they only affect manual shards build.
- Adding Intel-Mac bottles to the homebrew-coveralls formula. Separate concern; this issue addresses the source-build path that fails for any user without a matching bottle, regardless of platform.
References
Background
homebrew-coveralls#71 reported
brew install coverallsfailing during the source-build path on macOS with Crystal 1.20.0. The compiler error originates in Crystal stdlib, not in our code:Upstream Crystal issue: crystal-lang/crystal#16886. Crystal maintainer @straight-shoota's response is the load-bearing fact:
Crystal 1.20.1 (released 2026-04-29) restored the behavior via explicit forwarders, so the surface symptom is patched in the latest Crystal. However, coverage-reporter is still relying on accidental, undocumented stdlib behavior that the Crystal team has signaled they consider unintentional. A future Crystal minor could remove it again, regressing us silently.
Root cause in this repo
There is exactly one call site in coverage-reporter where
to_json/to_pretty_jsonis invoked on anHTTP::Headersvalue:src/coverage_reporter/api/jobs.cr:51:headersis a localHTTP::Headersconstructed fromDEFAULT_HEADERS.dup+ a few merges (lines 42–46). The data payload that gets sent to Coveralls (built inbuild_requestand serialized atjobs.cr:58andwebhook.cr:41) is a plainHash— noHTTP::Headersinside. The trigger is purely the debug-log formatting.Object#to_pretty_jsonexpands roughly to:…which calls
to_json(json_builder)onheaders. In Crystal 1.20.0 that overload was missing onHTTP::Headers; in 1.20.1 it's restored as an undocumented forwarder.Proposed fix
Convert
HTTP::Headersto a plainHashbefore serializing, so we go through documented JSON paths only:(
headers.inspectis also acceptable if a JSON-shape isn't required for the debug output.)Acceptance criteria
src/coverage_reporter/api/jobs.cr:51no longer invokes ato_jsonpath on a rawHTTP::Headers.grep -rn 'to_json\|to_pretty_json' src/shows no remaining call sites taking anHTTP::Headersdirectly.Api::Jobs#send_requestunderLog::Level::Debugand asserts the debug headers line renders without compile or runtime error.make testpasses.make buildsucceeds against Crystal 1.20.0 and 1.20.1+. (Validate locally with whichever Crystalbrewinstalls, plus one older known-good — e.g., 1.19.1.)Suggested workflow for the next Claude Code session
This repo has the
superpowersplugin globally available. Recommended sequence:superpowers:systematic-debugging— root cause is already established above; you do not need to re-investigate. Use the trace as your Phase-1 finding.superpowers:writing-plans— turn the acceptance criteria into a concrete plan: single-file source change + one regression spec + version bump inshard.ymlandsrc/coverage_reporter.cr+ tag + release.superpowers:test-driven-development— write the failing regression spec first, then change line 51, then watch it go green.superpowers:verification-before-completion— runmake testandmake buildbefore claiming done; smoke-test the binary against a 500 response with--debugenabled.superpowers:finishing-a-development-branch— open a PR; after merge, runmake new_version(seeREADME.md) to tag 0.6.18.brew update && brew upgrade coveralls.Out of scope
shard.ymlor in the Homebrew formula. There is nocrystal@<X>formula in homebrew-core, so a clean pin isn't available; andshard.ymlconstraints don't influence Homebrew's choice of compiler — they only affect manualshards build.References
src/coverage_reporter/api/jobs.cr:51