Update carp-reader to 0.3.9 to escape control bytes in diagnostics - #27
Conversation
angler prints Form.str output in every diagnostic's `at:` line, so on carp-reader 0.3.7 linting a file whose string literals hold a control byte wrote that byte straight to the reader's terminal. 0.3.9 renders \a, \b, \v and \f as escapes and anything else as \uXXXX. 0.3.8, picked up on the way, aligns escape parsing with the reference compiler: unknown escapes pass through as written rather than failing the file. Form.str is also compared for equality when matching pattern rules (match-pattern, single-use-let), so the rendering change could in principle change what angler considers the same form. It does not: the escape mapping stays injective because a backslash is itself escaped. Probed with a repeated-metavar rule over control-byte pairs and against a literal backslash-a; every verdict is identical on 0.3.7 and 0.3.9. Linting all 91 .carp files across the sibling repos produces byte-identical diagnostics on both versions, and --fix --dry-run output matches too, since replacements are byte-sliced from the original source rather than rendered. The invalid-escape parse-error test moves from \q, which 0.3.8 now accepts, to \uZZZZ, which is still rejected. The three added tests all fail on 0.3.7 and pass on 0.3.9; the fix test guards the byte-slice path and passes on both.
There was a problem hiding this comment.
Build & Tests
Checked out claude/bump-carp-reader-0-3-9 at 43e114a and built it here.
carp -x test/angler.carp— 418 passed, 0 failed, exit 0. Matches the claim.carp -b main.carp— builds clean.- CI green on both
ubuntu-latestandmacos-latest; the run'shead_shais43e114aandci.ymlhas nocontinue-on-error. - Merge-base is the current
origin/main(8324876) — no drift. carp -x gendocs.carpleavesgit statusempty.carp-fmt --checkand the branch's own binary self-lintingangler.carp main.carp gendocs.carp test/angler.carpare both clean, rc=0. The CI smoke test still exits 1 on a file with findings.
The headline fix is real, and I checked it at byte level rather than by reading rendered text — which matters here, because a terminal turns a raw control byte into caret notation and makes the two cases look alike. Building the CLI at both pins and linting a fixture holding a bell escape, then od -c on the captured output:
0.3.7: a t : ( d o " \a " ) <- one byte, 0x07
0.3.9: a t : ( d o " \ a " ) <- two chars, backslash + a
Teeth on the new assertions are exact. Re-pinning to 0.3.7 and re-running the suite gives rc=3, and the three failures are precisely the three new assertions — an unknown escape is read as written, a named control escape reaches the diagnostic as an escape, an unnamed control byte reaches the diagnostic as \uXXXX. 415 + 3 = 418, so nothing else moved. The fourth new assertion (the --fix one) passes on both pins, as the PR says by design.
The --fix safety claim holds on exactly the input that could break it. This is the thing I most wanted to disprove: if a fix re-rendered through Form.str instead of slicing source bytes, then any escape the reader normalises would be silently rewritten into the user's file. Fixture with "\7", "\1" and "\q" inside (do …) forms, real --fix (not dry-run), od -c before and after — all three escapes survive byte-for-byte; the only change is the (do …) unwrap. Confirmed, not assumed.
Differential over real code, wider than the PR's. I built both pins and swept 191 .carp files across the workspace (the PR used 91), same exclusions CI uses: output is byte-identical, and the sweep is not empty — 158 at: diagnostics across 27 files. The harness demonstrably distinguishes the binaries, as the fixtures above show.
One honest caveat on that sweep, which the PR body already half-concedes: I checked the corpus for the input class the 0.3.9 change actually acts on, and zero of the 191 files contain a raw control byte inside a string literal. So the byte-identical result is evidence of no regression, but it carries no information about the headline change — all of that evidence comes from the synthetic fixtures.
Form equality. The injectivity argument holds, and for the right reason: "\a" and a source file holding a raw bell byte are the same string value after parsing, so Form.str rendering both as \a is correct rather than a collision. Literal backslash-a is written \\a in source, parses to two characters, and renders as \\a. No match-pattern verdict changes.
Findings
1. The new CHANGELOG entry has two factual errors
This repo has a CHANGELOG and the PR writes to it, so the text ships.
(a) "any other control byte renders as \uXXXX" is false for newline. carp-reader deliberately exempts byte 0x0A — its condition is (and (control-code? i) (Int./= i 10)), and its own Form.str docstring is explicit: "the one exception is a literal newline inside a string, which stays raw." angler's entry drops that exception, and so does the PR title ("diagnostics never emit raw control bytes"). Verified with od -c on a fixture holding a multi-line string: the at: line carries a raw 0x0A and one diagnostic is split across two physical lines, which breaks any line-oriented consumer of angler's output. This is not exotic — 90 of the 191 files in my corpus contain a multi-line string literal. (Also minor: tab and CR render as \t and \r, not \uXXXX. Harmless, since they are still escaped, but the sentence covers them too.)
(b) "Runs of digits after \ are read as decimal character codes" — they are read as hex. carp-reader routes the run through read-hex, and its own source comment says so ("the reference feeds octal-looking runs through its hex reader, quirk included"). Measured on both the reference compiler and the reader:
| source | value | decimal reading would give |
|---|---|---|
"\65" |
e (0x65) |
A |
"\12" |
0x12 | 0x0C |
"\123" |
U+0123 | U+007B |
Not a code problem — just the wrong word in a user-facing line.
2. Single-digit \0–\7 diverge from the reference compiler, which is the direction 0.3.8 was supposed to close
The stated justification for taking 0.3.8 is reference fidelity, and it holds for the case the PR tested ((String.length "\q") is 2, confirmed here). But I measured the compiler across the whole digit-escape range, and its rule is run length ≥ 2, not "starts with 0–7":
| source | reference compiler | carp-reader 0.3.9 |
|---|---|---|
"\1" |
backslash + 1 (2 bytes) |
0x01 — diverges |
"\7" |
backslash + 7 (2 bytes) |
0x07 — diverges |
"\9" |
backslash + 9 |
backslash + 9 — agrees |
"\07" |
0x07 | 0x07 — agrees |
"\65" |
e |
e — agrees |
"\41x" |
Ax |
Ax — agrees |
carp-reader's guard is (and (Char.>= esc \0) (Char.<= esc \7)) followed by a scan of that same class, so a run of length 1 is accepted where the reference passes it through. Linting a file containing "\7" renders at: (do "\a") — the diagnostic disagrees with the source text.
Scope, stated fairly. This is a carp-reader bug, not angler's, and it is still a net improvement: on 0.3.7 a file containing "\7" was rejected outright with a parse error, so angler linted nothing. And --fix is unaffected — I verified that above on this exact input. The blast radius is the cosmetic at: rendering plus Form.str equality in match-pattern/single-use-let. I'd raise it upstream rather than hold this PR for it; I mention it here because this is the PR that adopts the behaviour and cites reference fidelity as the reason.
Nothing else. The pin change itself is right, carp-fmt's collapsing of the two multi-line-string assertions did leave the escapes untouched (I diffed them), and moving the invalid-escape test from \q to \uZZZZ is the correct adjustment — \uZZZZ is still rejected on both pins.
Verdict: revise
The code change is correct and unusually well evidenced: the bell-byte fix reproduces at byte level, the three new assertions fail on 0.3.7 and only those three, --fix provably slices source rather than re-rendering, and a 191-file differential is byte-identical. What needs a pass is the CHANGELOG entry — it promises that every control byte is escaped when newline is explicitly exempt upstream (and 90 of 191 real files carry a multi-line string), and it says "decimal" where the reader uses hex. That is a two-line edit. Finding 2 is upstream and shouldn't block; worth an issue on carp-reader.
Two sentences were measurably wrong. carp-reader exempts byte 0x0A from escaping — its guard is `(and (control-code? i) (Int./= i 10))` — so a standalone newline still reaches the terminal raw and a diagnostic quoting a multi-line string literal is split across two physical lines. Verified with `od -c` on the linter's own output: the `at:` line for a two-line string literal carries a raw 0x0A. Tab and CR are also not `\uXXXX`; they render as `\t` and `\r`. Digit runs after a backslash are read as hex, not decimal. The reader routes them through `read-hex`, and the reference compiler agrees: `"\65"` is `e` (0x65) on both, and `"\12"` is 0x12. Text only — no code change.
|
Thanks — both errors in Finding 1 are real. I re-measured each one myself rather than taking the review on trust, at byte level via (a) newline is exempt, so the "any other control byte" claim was false
That Also confirmed the minor half: tab and CR are escaped, but as One nuance that came out of that third line and which the entry now leans on: CRLF is emitted as the escape pair (b) digit runs are hex, not decimal
( I also re-checked the one clause in that bullet the review didn't flag, since I was rewriting the sentence around it: the character literal What changedChangelog now reads:
The title carried the same over-claim and is now "Update carp-reader to 0.3.9 to escape control bytes in diagnostics". The PR body had it too (it also said "anything else as Finding 2Agreed on both the diagnosis and the scope: the reader's guard is Re-verification after the changeUnchanged from your run, as expected for a text-only commit, but re-run rather than assumed: |
There was a problem hiding this comment.
Build & Tests
Checked out claude/bump-carp-reader-0-3-9 at 29a7941 and built it here.
carp -x test/angler.carp— 418 passed, 0 failed, exit 0. Unchanged from the last round, as expected for a text-only commit.carp -b main.carpbuilds clean. CI green on bothubuntu-latestandmacos-latest; the run'shead_shais29a7941, so it covers the follow-up. Nocontinue-on-errorinci.yml.29a7941is a fast-forward on43e114a, and the diff isCHANGELOG.mdonly (8 insertions, 5 deletions) — no code change, exactly as stated.- Merge-base is still the current
origin/main(8324876), so the entry is filed against the right release boundary: it sits under## Unreleased, above## [0.5.0]. No drift since the last review. - Smoke test, self-lint, format check and gendocs all reproduce:
(do x)→ a[lonely-do]finding, exit 1; a finding-free file → exit 0. I checked both polarities deliberately, because an exit 1 from a missing fixture passes that test vacuously — my first attempt did exactly that before I caught it. Self-lint over CI's file set rc=0,carp-fmt --checkrc=0,carp -x gendocs.carpleavesgit statusempty.
Prior feedback
Finding 1 (a) and (b) are both fixed, and I checked the corrected text against carp-reader 0.3.9's source before measuring its behaviour. The escape table is escape-string-byte (carp-reader.carp:70-92):
7 -> \a 8 -> \b 9 -> \t 11 -> \v 12 -> \f 13 -> \r
(and (control-code? i) (Int./= i 10)) -> (fmt "\\u%04x" i)
otherwise -> the raw byte
with (defn control-code? [i] (or (Int.< i 32) (Int.= i 127))). So the entry's list of six escapes is exactly the code's list of six, \uXXXX covers the rest of 0..31 plus 127, and byte 0x0A is the single fall-through. The corrected entry says all three of those things and claims nothing beyond them.
Measured through the branch's own binary, reading the at: line with od -c so that an escape and a raw byte cannot be confused:
| byte in the literal | rendered in at: |
|
|---|---|---|
0x07 |
backslash a |
two characters, not one byte |
0x08 |
backslash b |
|
0x09 |
backslash t |
not \uXXXX — the entry now says so |
0x0B |
backslash v |
|
0x0C |
backslash f |
|
0x0D |
backslash r |
not \uXXXX — the entry now says so |
0x01 |
backslash u 0 0 0 1 |
|
0x7F |
backslash u 0 0 7 f |
control-code? covers 127 |
standalone 0x0A |
one raw byte | the exception |
0x0D 0x0A |
backslash r backslash n |
escape pair — the newline is not raw |
"Standalone" is exactly the right word, and I confirmed why it is needed. escape-string (:98-112) detects CR-followed-by-LF and appends the literal pair \r\n, advancing by two — so a newline only reaches the raw fall-through when no CR precedes it. That is a real distinction rather than a hedge, and this entry is the only place it is written down.
The consequence the entry now names is real. One diagnostic on a fixture holding a two-line string literal occupies three physical output lines:
multiline.carp:1:12: [unused-let-binding] let binding 'x' is never used
at: (let [x "alpha
beta"] 2)
od -c shows a bare 0x0A between alpha and beta. Line-oriented consumers of angler's output still have to handle that, and the changelog now says so instead of implying the opposite.
(b) hex, not decimal — confirmed against both angler and the reference compiler. Values below are given in hex to keep raw control bytes out of this comment:
| source | value (both agree) | angler renders it as | a decimal reading would give |
|---|---|---|---|
"\65" |
0x65 |
e |
0x41, i.e. A |
"\12" |
0x12 |
backslash u0012 |
0x0C, i.e. \f |
"\41x" |
0x41 then x |
Ax |
0x29 then x, i.e. )x |
The other two clauses in that bullet hold as well: an unrecognised escape passes through as written ("\q" renders as backslash-backslash-q), and a character literal takes a codepoint escape (A renders as \A).
The title is corrected — "…to escape control bytes in diagnostics" rather than "…so diagnostics never emit raw control bytes" — and the PR body carries the exception too, so the over-claim is gone from all three places it appeared.
Finding 2 is still open and was correctly left alone. I re-measured it rather than assuming the last round's table still holds:
| source | reference compiler | carp-reader 0.3.9 | |
|---|---|---|---|
"\1" |
backslash then 1 (two characters) |
0x01, rendered backslash u0001 |
diverges |
"\7" |
backslash then 7 (two characters) |
0x07, rendered backslash a |
diverges |
"\07" |
0x07 |
0x07 |
agrees |
"\9" |
backslash then 9 |
backslash then 9 |
agrees |
Unchanged: still a carp-reader bug rather than angler's, and still strictly better than 0.3.7, which rejected such a file outright and linted nothing. Deferring it here was the right call.
Worth noting the corrected entry does not accidentally over-claim on this. The reference-fidelity sentence is scoped to escapes angler does not recognise, and \1 is one it does recognise, so the two bullets stay consistent with one another. An upstream issue on carp-reader is still owed; that is your call and not a blocker here.
Findings
None. The correction is accurate, minimal and text-only; nothing in the code moved, and the suite, self-lint, format check and gendocs all confirm that.
One curiosity from sweeping the control range, recorded as an observation and explicitly not a defect: byte 0x00 is a control byte that does not render as the escape \u0000. A literal containing it is truncated there, so "A" + 0x00 + "B" renders as "A". That happens at read time because Carp strings are NUL-terminated, so the escape path never sees the byte — it is not reachable and needs no changelog caveat.
Verdict: merge
Both errors from the last round are fixed, and both fixes hold up against the source as well as against the runtime: the entry's list of six escapes is exactly escape-string-byte's list, \uXXXX genuinely covers 0..31 plus 127, the newline exception is real and correctly qualified as standalone because CRLF escapes to \r\n, and digit runs really are hex on both angler and the reference compiler. The title and PR body were corrected alongside. The pin bump itself was right the first time and is untouched. Nothing further from me.
Moves the
carp-readerpin from0.3.7to0.3.9.carp-fmttook thesame bump on 2026-08-12; angler was missed.
Why it matters here
angler renders
Form.stroutput into every diagnostic'sat:line. On0.3.7a string literal holding a control byte was written to thereader's terminal as that raw byte — so linting a file containing a
\arang the bell, and other control bytes garbled the report.0.3.9renders
\a,\b,\t,\v,\fand\ras those escapes andanything else as
\uXXXX— with one exception:carp-readerdeliberately leaves a standalone newline raw, so a diagnostic quoting a
multi-line string literal is still split across two physical lines.
0.3.8comes along for the ride and aligns escape parsing with thereference compiler: an unrecognised escape passes through as written
instead of failing the whole file with a parse error. Verified against
the compiler directly —
(String.length "\q")is2there, so anglerwas rejecting files carp itself accepts.
The thing worth checking: form equality
Form.stris not only printed.match-patterncompares it forequality when a pattern rule binds a metavariable twice, and
single-use-letcompares a binding name against the body(
angler.carp:253,:258,:278,:1251). A change in how escapesrender could therefore change what angler considers "the same form".
It doesn't. The escape mapping stays injective because a backslash is
itself escaped as
\\, so an escape can never collide with literaltext that looks like one. Checked empirically with a
repeated-metavariable rule (
[(?x ?x) …]), and the verdicts areidentical on both versions:
\avs\a\avs\b\avs literal backslash-a\^Avs\^A\^Avs\^B\avs\^G\^Avs literal\^Atext(foo foo)/(foo bar)Differential over real code
Built the CLI at both pins and ran each over all 91
.carpfiles in thesibling repos under
~/carpentry:--fix --dry-run: output is identical too, except for the one linethat is this PR's own edit to
angler.carpConfirmed rather than assumed that
--fixis unaffected: on a fixturewhose string literals do hold control bytes,
--fix --dry-runoutputis identical on both pins, because replacements are
String.byte-slicedout of the original source instead of re-rendered. That same fixture is
what shows the sweep has teeth —
at: (do "\a")on0.3.9against araw BEL on
0.3.7.Tests
\q, which0.3.8now accepts, to
\uZZZZ, which is still rejected.parses, and a named control escape and an unnamed control byte each
reach the rendered diagnostic as
\a/\^A. All three fail on0.3.7and pass on0.3.9.--fix; it passes onboth pins by design.
carp -x test/angler.carp→ 418 passed, 0 failed.carp -x gendocs.carpleavesgit statusempty.carp-fmt --checkand angleritself are clean over the same file set CI uses, and the CI smoke test
still exits non-zero on a file with findings.
carp-fmtcollapsed the two multi-line-string assertions onto one line;that is its own formatting and it left the escapes untouched.
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.