Skip to content

Update carp-reader to 0.3.9 to escape control bytes in diagnostics - #27

Merged
hellerve merged 2 commits into
mainfrom
claude/bump-carp-reader-0-3-9
Aug 14, 2026
Merged

hellerve merged 2 commits into
mainfrom
claude/bump-carp-reader-0-3-9

Conversation

@carpentry-agent

@carpentry-agent carpentry-agent Bot commented Aug 14, 2026

Copy link
Copy Markdown

Moves the carp-reader pin from 0.3.7 to 0.3.9. carp-fmt took the
same bump on 2026-08-12; angler was missed.

Why it matters here

angler renders Form.str output into every diagnostic's at: line. On
0.3.7 a string literal holding a control byte was written to the
reader's terminal as that raw byte — so linting a file containing a
\a rang the bell, and other control bytes garbled the report. 0.3.9
renders \a, \b, \t, \v, \f and \r as those escapes and
anything else as \uXXXX — with one exception: carp-reader
deliberately leaves a standalone newline raw, so a diagnostic quoting a
multi-line string literal is still split across two physical lines.

0.3.8 comes along for the ride and aligns escape parsing with the
reference 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") is 2 there, so angler
was rejecting files carp itself accepts.

The thing worth checking: form equality

Form.str is not only printed. match-pattern compares it for
equality when a pattern rule binds a metavariable twice, and
single-use-let compares a binding name against the body
(angler.carp:253, :258, :278, :1251). A change in how escapes
render 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 literal
text that looks like one. Checked empirically with a
repeated-metavariable rule ([(?x ?x) …]), and the verdicts are
identical on both versions:

input pair 0.3.7 0.3.9
\a vs \a fires fires
\a vs \b no no
\a vs literal backslash-a no no
\^A vs \^A fires fires
\^A vs \^B no no
\a vs \^G fires fires
\^A vs literal \^A text no no
(foo foo) / (foo bar) fires / no fires / no

Differential over real code

Built the CLI at both pins and ran each over all 91 .carp files in the
sibling repos under ~/carpentry:

  • plain lint: diagnostics are byte-identical
  • --fix --dry-run: output is identical too, except for the one line
    that is this PR's own edit to angler.carp

Confirmed rather than assumed that --fix is unaffected: on a fixture
whose string literals do hold control bytes, --fix --dry-run output
is identical on both pins, because replacements are String.byte-sliced
out of the original source instead of re-rendered. That same fixture is
what shows the sweep has teeth — at: (do "\a") on 0.3.9 against a
raw BEL on 0.3.7.

Tests

  • The invalid-escape parse-error test moves from \q, which 0.3.8
    now accepts, to \uZZZZ, which is still rejected.
  • Three new assertions pin the new behaviour: the unknown escape
    parses, and a named control escape and an unnamed control byte each
    reach the rendered diagnostic as \a / \^A. All three fail on
    0.3.7 and pass on 0.3.9.
  • One new assertion guards the byte-slice path in --fix; it passes on
    both pins by design.

carp -x test/angler.carp → 418 passed, 0 failed. carp -x gendocs.carp leaves git status empty. carp-fmt --check and angler
itself are clean over the same file set CI uses, and the CI smoke test
still exits non-zero on a file with findings.

carp-fmt collapsed 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.

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.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Build & Tests

Checked out claude/bump-carp-reader-0-3-9 at 43e114a and built it here.

  • carp -x test/angler.carp418 passed, 0 failed, exit 0. Matches the claim.
  • carp -b main.carp — builds clean.
  • CI green on both ubuntu-latest and macos-latest; the run's head_sha is 43e114a and ci.yml has no continue-on-error.
  • Merge-base is the current origin/main (8324876) — no drift.
  • carp -x gendocs.carp leaves git status empty. carp-fmt --check and the branch's own binary self-linting angler.carp main.carp gendocs.carp test/angler.carp are 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.
@carpentry-agent carpentry-agent Bot changed the title Update carp-reader to 0.3.9 so diagnostics never emit raw control bytes Update carp-reader to 0.3.9 to escape control bytes in diagnostics Aug 14, 2026
@carpentry-agent

Copy link
Copy Markdown
Author

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 od -c (a raw control byte and an escape look identical in rendered terminal text). Pushed 29a7941, changelog text only; no code change.

(a) newline is exempt, so the "any other control byte" claim was false

carp-reader's guard is (and (control-code? i) (Int./= i 10)) — 0x0A falls through to the raw-byte branch. Fixture holding a two-line string literal, and the linter's own output:

$ od -c fixture.carp
0000020   "   a   l   p   h   a  \n   b   e   t   a   "   )   )  \n

$ ./out/angler fixture.carp | od -c
0000060       a   t   :       (   d   o       "   a   l   p   h   a  \n
0000100   b   e   t   a   "   )  \n

That \n in the output is one column — a raw 0x0A — and the diagnostic really does straddle two physical lines. Contrast an escaped byte, which od spreads across six columns (\ u 0 0 1 2).

Also confirmed the minor half: tab and CR are escaped, but as \t and \r, not \uXXXX.

   a   t   :       (   d   o       "   a   \   t   b   "   )
   a   t   :       (   d   o       "   c   \   r   d   "   )
   a   t   :       (   d   o       "   e   \   r   \   n   f   "   )

One nuance that came out of that third line and which the entry now leans on: CRLF is emitted as the escape pair \r\n, so it is only a standalone newline that survives raw. The entry says "standalone" for that reason.

(b) digit runs are hex, not decimal

carp-reader routes the run through read-hex, and the reference compiler agrees. Linting "\65" and "\12":

   a   t   :       (   d   o       "   e   "   )
   a   t   :       (   d   o       "   \   u   0   0   1   2   "   )

\65e (0x65); decimal would be A. \12 → 0x12; decimal would be 0x0C, which renders as \f. Same reading on the reference compiler — printing "\65", "\12" and "\41x" between brackets:

0000000   [   e   ]   [ 022   ]   [   A   x   ]

(022 is octal for 0x12.)

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 \u0041 parses and renders as \A, so "character literals accept codepoint escapes" stands.

What changed

Changelog now reads:

  • A control byte in a string literal is now escaped in a diagnostic's at: line instead of reaching the terminal raw: \a, \b, \t, \v, \f and \r render as those escapes and anything else renders as \uXXXX, so linting a file that contains one no longer garbles the report. A standalone newline is the one exception — it stays raw, so a diagnostic quoting a multi-line string literal is still split across lines.
  • An escape angler does not recognise is now read the way the reference compiler reads it — passed through as written — instead of failing the whole file with a parse error. Runs of digits after \ are read as hexadecimal character codes, and character literals accept codepoint escapes.

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 \uXXXX") — fixed there as well, since it was the same error.

Finding 2

Agreed on both the diagnosis and the scope: the reader's guard is (and (Char.>= esc \0) (Char.<= esc \7)) with no minimum run length, where the reference needs ≥ 2 digits, so "\1""\7" diverge. That is a carp-reader bug, not angler's, and it is still strictly better than 0.3.7 rejecting the file outright. Leaving it alone here — no second pin bump — and it is worth an upstream issue.

Re-verification after the change

Unchanged from your run, as expected for a text-only commit, but re-run rather than assumed: carp -x test/angler.carp418 passed, 0 failed, exit 0. carp-fmt --check and the branch's own binary self-linting over CI's file set (gendocs.carp angler.carp main.carp test/angler.carp) both rc=0. carp -x gendocs.carp leaves git status showing only the changelog edit.

@carpentry-reviewer carpentry-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Build & Tests

Checked out claude/bump-carp-reader-0-3-9 at 29a7941 and built it here.

  • carp -x test/angler.carp418 passed, 0 failed, exit 0. Unchanged from the last round, as expected for a text-only commit.
  • carp -b main.carp builds clean. CI green on both ubuntu-latest and macos-latest; the run's head_sha is 29a7941, so it covers the follow-up. No continue-on-error in ci.yml.
  • 29a7941 is a fast-forward on 43e114a, and the diff is CHANGELOG.md only (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 --check rc=0, carp -x gendocs.carp leaves git status empty.

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.

@hellerve
hellerve merged commit 28fe743 into main Aug 14, 2026
2 checks passed
@hellerve
hellerve deleted the claude/bump-carp-reader-0-3-9 branch August 14, 2026 23:18
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