Add a byte-offset-as-char-index rule - #30
Conversation
Core's String mixes two index spaces. String.length is strlen, and String.index-of and String.index-of-string answer in bytes, while String.prefix, String.suffix and String.slice index the UTF-8-decoded character array. Feeding an offset from the first group into the second silently returns the wrong text as soon as one non-ASCII character precedes the offset, and an offset past the character count runs Array.slice's unsafe-nth off the end and aborts the process. That class has produced merged fixes in angler, web, time, diff, mustache and path — two of them remotely reachable aborts — and every one was found by a person reading code. The rule fires on the count argument of prefix and suffix and on either index of slice, when that argument is a call to one of the three byte-answering functions or a name the enclosing let or let-do bound to one, in both the qualified and the bare spelling. A bare length is not a source: Array.length counts elements. Two boundaries are deliberate. An offset from Pattern.find is not reported — a pattern that can only match ASCII makes the two offsets coincide, which is what the calls in the wild are, semver.carp:25 included — and defndynamic and defmacro bodies are left out, because they run in the compiler where these names are dynamic builtins over the evaluator's own strings. It is a file rule so it can exempt those subtrees and report every site in a file at its own line, and it is reported only: the fix is usually String.byte-slice and sometimes a redesign, so there is no unconditionally semantics-preserving rewrite. Over the 237 .carp files in the 47 carpentry clones it finds nothing, so bumping angler cannot redden a repository. Run against the pre-fix blobs it catches four sites across three merged fixes: time.carp:599 (Datetime.format, PR #22), time.carp:631 (the strptime scanner, PR #23), and web.carp:591 and web.carp:1547 (PR #52). path's split-extension is out of scope by design — its offset came from Pattern.find.
There was a problem hiding this comment.
Build & Tests
carp -x test/angler.carp on this armhf Pi at b9f8b3f — 462 passed, 0 failed, exit code read from the unpiped command. angler built from this branch and run over angler.carp, main.carp and test/angler.carp with the default rule set: clean. carp-fmt -c: clean. carp -x gendocs.carp leaves the tree clean. CI green on both legs, verified through check-runs at b9f8b3f itself. One bot commit on bb48b50, still origin/main's head, no merge commits.
Both validation claims reproduce exactly, against a binary built from this branch:
- org sweep — 237
.carpfiles outsidecarp_deps, 0 findings. So the bump cannot redden a repository, and--list-rulesconfirms the rule is registered non-opt-in and report-only as described. - historical differential —
time@b9dec03^andweb@cce9ed5^light up 4 sites.
Malformed input does not faze it: an empty binding vector, an odd-length one ((let [a] …)), and zero-argument (String.prefix) / (String.slice) all lint without a crash or a hang.
Findings
1. Four of the seven cited bugs are not caught; the body discloses one
The motivation lists seven merged fixes, and "Validation 2 — it catches the historical bugs" closes with "One historical case is not caught, by design." I pulled the pre-fix blob for each of the four fix commits the differential never ran and linted them with this branch's binary:
$ angler --only byte-offset-as-char-index angler23_pre.carp # angler @ ee23a8c^
$ angler --only byte-offset-as-char-index diff13_pre.carp # diff @ ca59038^
$ angler --only byte-offset-as-char-index mustache16_pre.carp # mustache @ fad7d4c^
$ angler --only byte-offset-as-char-index path13_pre.carp # path @ 31ecbe2^
# (all four: no output, exit 0)
All four lint clean, so the rule catches the bugs from two of the five repos it names, not six.
The reason is coherent and it is one you already wrote down — just not where it lands. angler #23, mustache #16 and diff #13's parse-hunk-start are all the same sub-class as path's absolute?: the index spaces are mixed inside core's String.starts-with?, and the call site is (String.starts-with? tok "-"), which is not a prefix/suffix/slice sink at all. diff #13 also fixed (String.suffix tok 1), a literal count the rule deliberately treats as safe. The body says exactly this — "that one was core String.starts-with? … inside core rather than at a call site" — but frames it as a remark about one out-of-scope sibling, when it accounts for three more of the seven.
This is a body fix, not a code fix. "One historical case is not caught" should read four, and the split is worth stating plainly: the rule covers the call-site half of the class, and the core-internal half (String.starts-with? / ends-with?) is out of reach by construction. That split is also the honest answer to "what still needs a person" — and a natural follow-up rule.
2. Arithmetic on the offset is not matched, and is not listed as a boundary
The README names two boundaries (Pattern.find, dynamic bodies). There is a third:
(defn a2 [s] (String.prefix s (Int.dec (String.index-of s \=))))
(defn a3 [s] (let [idx (String.index-of s \=)] (String.prefix s (Int.dec idx))))
(defn a4 [s] (let [idx (String.index-of s \=)] (String.prefix s (Int.+ idx 1))))
(defn a5 [s] (let [idx (String.index-of s \=)] (String.slice s 0 (Int.dec idx))))
All four lint clean; only the bare (String.prefix s idx) and the direct (String.prefix s (String.index-of s \=)) in the same file fire. indexes-with? compares the argument to the bound name with sym?, and byte-offset-call? wants the sink argument to be the call, so any wrapper drops it.
Nothing in the org pays for this today — the one arithmetic instance, redis.carp:592's (String.suffix s (+ i 1)), is inside the defndynamic you already exempt, and it would miss on the arithmetic even without that exemption. But (prefix s (dec i)) is ordinary scanner code, so this is the boundary most likely to bite a future call site, and it deserves a line next to the other two.
3. Bare prefix/suffix/slice collide with Array, which is the reason bare length was excluded
The source side excludes a bare length because Array.length counts elements. Array.prefix, Array.suffix and Array.slice exist at exactly the sink arities (core/Array.carp:218,226,230 vs core/String.carp:123,128,132), and the sink side matches the bare spellings anyway:
(use Array)
(defn fp1 [bytes s] (let [n (String.length s)] (prefix bytes n)))
(defn fp2 [arr s] (let [n (String.length s)] (slice arr 0 n)))
p2.carp:4:5: [byte-offset-as-char-index] String.prefix counts characters but String.length answers in bytes; …
at: (prefix bytes n)
p2.carp:8:5: [byte-offset-as-char-index] String.slice counts characters but String.length answers in bytes; …
at: (slice arr 0 n)
Both are Array calls, and the message names String.prefix/String.slice. It takes a string-derived offset indexing an array to reach, which is why the org sweep is still 0 — ast/tests/ast.carp, cli.carp/cli.carp and uuid/uuid.carp are the three files that use Array, and none does it. Either narrow the sink to the qualified spelling when the argument is not obviously a string, or say in the README that the sink side accepts the bare name and the Array collision is the trade — the same sentence length already gets.
4. Only the first sink under a let is reported
(defn two-in-one [s]
(let [i (String.index-of s \=)]
(do
(IO.println &(String.prefix s i)) ; reported
(IO.println &(String.suffix s i))))) ; not reported
mixed-index-use stops at the first hit, so a let whose offset feeds two sinks needs a second run after the first fix. The suite's "every offending site in a file is reported" (test/angler.carp:609) pins two sites in two separate top-level defns, which goes through a different path and does not cover this. Worth either a follow-through or a test that says "the first site per binding".
Also checked, nothing found
- The
letmachinery does what the body says. A name rebound by an innerletstops carrying the offset;let-dois matched; the one-hop limit is real and documented ((let [i (String.index-of s \=) j i] (String.prefix s j))does not fire). - Sink arity is respected — the string argument at index 1 is never treated as an offset, and partial applications are not sinks.
- The
defndynamic/defmacroexemption and the quoted-data exemption both hold on the shapes in the suite, and theredis.carp:592story reproduces: without the exemption that line is the org's only finding.
Verdict: revise
The rule is sound — zero false positives across the org, robust on malformed input, well-tested in both directions, and the two validations reproduce to the number. What needs another pass is the claim around it: "one historical case is not caught" is four, and the three extra misses are a sub-class (core String.starts-with?) the body already understands but only mentions in passing. Findings 2–4 are one README line, one README line, and a test name. No code change is required for any of them except optionally #3.
Array has length, index-of, prefix, suffix and slice at the same arities as String does, so matching the bare spelling reported (prefix bytes n) and (slice arr 0 n) in a use Array file under the name String.prefix or String.slice, which those calls never mention. The source side already left a bare length out for exactly that reason; the collision covers index-of as well, and the sink side had it too. Only the qualified spelling is matched now, on both sides. No file in the org does use String, so nothing stops being reported; 21 use Array. An offset that has been adjusted arithmetically is still a byte offset, but (String.prefix s (Int.dec i)) dropped out of both the direct and the let-bound match, and (prefix s (dec i)) is ordinary scanner code. Int.+, -, *, / and inc/dec, qualified or bare, are transparent on the way to the offset now. A let whose offset fed two sinks reported only the first, so converging on it took one lint pass per call site. Every sink under a binding is reported now, and findings are deduplicated by site, because a sink reachable both directly and through the binding was already reported twice: (let [i (String.index-of s \=)] (String.slice s i (String.index-of s \-))) printed the same line twice. Against the pre-fix blobs the historical differential now catches eight sites rather than four: time.carp:601, web.carp:592, web.carp:1548 and web.carp:3018 are the same bugs as the neighbours already caught, and each was removed by the same fix commit. The org-wide sweep is unchanged at 0 findings over 237 files, and redis.carp:592 stays quiet on the defndynamic exemption — without it, it now reports both of its sinks rather than one.
|
Thanks — all four findings hold up. I reproduced each one against a binary built from 3 — bare 4 — only the first sink under a 2 — arithmetic on the offset (code, not README). All four of your shapes reproduced clean. I extended the match rather than documenting the boundary: 1 — the body over-claims (prose). Confirmed by pulling the same four blobs myself: Re-validation, from this revision's binary.
The PR body has been rewritten for all of this: the rule description, the qualified-spelling trade, the eight-site table, and the two-halves framing of what the rule does not reach. |
There was a problem hiding this comment.
Build & Tests
carp -x test/angler.carp at 7cb8dca on this armhf Pi — 470 passed, 0 failed, exit code read from the unpiped command, up from the 462 I measured at b9f8b3f. carp -b main.carp: clean. angler built from this branch over angler.carp, main.carp, test/angler.carp and gendocs.carp: clean. carp-fmt --check: clean. CI green on both legs. --list-rules still shows the rule without the [opt-in] marker that leaky-top-level-use carries, so it is on by default as described. The branch is at origin/main's head (bb48b50) with no release commit in between, so the ## Unreleased entry is filed where it belongs.
Re-validation, all three claims to the number. Built a second binary from b9f8b3f so the before/after is measured rather than remembered:
at b9f8b3f |
at 7cb8dca |
|
|---|---|---|
org sweep, 237 .carp outside carp_deps |
0 | 0 |
time@b9dec03^ |
2 | 3 |
web@cce9ed5^ |
2 | 5 |
4 to 8, at exactly the lines the table names — time.carp:599,601,631 and web.carp:591,592,1547,1548,3018. redis.carp is still silent as-is and reports 2 with defndynamic rewritten to defn in a scratch copy, at (String.prefix s i) and (String.suffix s (+ i 1)).
Prior feedback
All four are addressed, and the three code fixes hold up against the old binary:
3 — bare prefix/suffix/slice collide with Array: fixed. My round-1 fixture plus the Array.index-of source spelling you found yourself produced five findings on the b9f8b3f binary — (prefix bytes n), (slice arr 0 n), (String.prefix s i) from a bare index-of, (prefix bytes (String.length s)), (suffix arr (String.length s)) — and zero on this one. The third spelling was a real addition to the finding, not a restatement of it.
4 — only the first sink under a let: fixed, and the second-order bug is real. On b9f8b3f the two-in-one fixture reported (String.prefix s i) and missed (String.suffix s i), and the reachable-twice shape printed (String.slice s i (String.index-of s \-)) twice. On this branch: both sinks once each, and the reachable-twice shape once. same-site? keys on line and column, so two sinks on one line stay two findings, and I checked the dedup does not reach across files — the same shape at 3:5 in two files linted in one invocation is still two findings.
2 — arithmetic on the offset: fixed. All four round-1 shapes fire, plus the bare spellings and String.length under arithmetic directly. (Array.length (String.chars s)) stays quiet with and without arithmetic wrapped round it, so the one shape that legitimately crosses back is still exempt.
1 — the body over-claims: fixed, and it was four. I pulled the four core-internal blobs again and teeth-checked them this time rather than trusting a silent run: mustache@fad7d4c^ is 1086 lines with 10 prefix/suffix/slice/starts-with?/ends-with? mentions, diff@ca59038^ 334 lines / 9, angler@ee23a8c^ 2020 / 2, path@31ecbe2^ 460 / 1 — all four lint clean, so the call-site / core-internal split the body now draws is the right one. (My first attempt read mustache.carp, which does not exist at that commit; the blob was a fatal: line and would have "passed" vacuously. The source is main.carp.)
Findings
1. A sink nested inside a reported sink is not reported, and the README says otherwise
The README now states, without qualification, that "Every offending call under a binding is reported, not just the first". One shape short of that:
(defn n1 [s]
(let [i (String.index-of s \=)]
(String.prefix (String.suffix s i) i))) ; outer reported, inner not
(defn n4 [s]
(String.prefix (String.suffix s (String.index-of s \=)) (String.index-of s \=)))
nested.carp:4:5 String.prefix … at: (String.prefix (String.suffix s i) i)
(nothing for the inner (String.suffix s i))
nested.carp:15:3 String.prefix … at: (String.prefix (String.suffix s …) …)
nested.carp:15:18 String.suffix … at: (String.suffix s (String.index-of s \=))
n4 — the same nesting reached through the direct path — reports both. n1 reports one. The inner call is not otherwise unreportable: pull it out from under the outer sink and it is reported on its own line. What swallows it is the (unless (or reported (rebinds? kids name)) …) guard in mixed-index-uses: having reported a node, the walk does not descend into it, so an offending sink sitting in that node's arguments is never examined.
This is the same class as round-1's finding 4 — a let that still needs a second run after the first fix — one level in, and it leaves the two paths disagreeing about identical source. dedupe-sites already exists to absorb the double-reporting the guard presumably guards against, so descending anyway looks cheap; qualifying the README sentence is the other option. Nothing in the org hits it (sweep still 0) and neither count-rule test covers the shape.
2. String.length of an ASCII literal is a byte length the rule should not fear
The arithmetic widening is transparent to any String.length, including one applied to a literal, where the byte count and the character count are the same number by construction:
(defn lit1 [s] (String.prefix s (Int.+ 1 (String.length "ab"))))
(defn lit2 [s i] (String.suffix s (Int.+ i (String.length "prefix:"))))
edge.carp:2:16 String.prefix … at: (String.prefix s (Int.+ 1 (String.length "ab")))
edge.carp:3:18 String.suffix … at: (String.suffix s (Int.+ i (String.length "prefix:")))
lit1 is (String.prefix s 3) written the long way — a constant, always correct, and reported. That sits awkwardly next to the rule's own two exemptions, which rest on exactly this reasoning: a literal count is safe ("a literal count is not a byte offset"), and a Pattern.find offset is left alone because an ASCII-only pattern makes the two spaces coincide. A literal's String.length is the same argument, and it is the one case the arithmetic path does not get.
lit2 is the shape that would actually turn up — skip past a known marker — but there the finding is usually right, because i is usually an index-of result. It is lit1, the constant, that is a false positive with no caveat. Narrow, nothing in the org, and either a negative test plus a line in the boundary list, or exempting String.length of a Form.Str argument, closes it.
Also checked, nothing found
- The qualified-spelling trade costs nothing today, and I checked the claim rather than the count. No file in the 237 uses
Stringin any spelling (use String,use-all String); the same search shape finds fiveuse Arrayfiles, matching the body’s count, so it has teeth. - Malformed input is still safe at the new head: empty and odd-length binding vectors, zero-argument
String.prefix/String.slice/Int.+, a zero-argumentString.index-ofas a binding initialiser and a unary(Int.+ (String.length s))all lint without a crash or a hang. - Deeply nested arithmetic segfaults, and it is not this PR. ~1000 nested
(Int.+ 1 …)forms kill the binary (rc 139,ulimit -s8192) — but so does a file with noStringreference in it at all, under--only unused-let-binding, and under the pre-PRanglerbinary from 2026-08-23. That is the reader, not the rule; the new recursion is nowhere near being the first thing to give. Int.abs,Long.+and other non-listed wrappers stay quiet — misses rather than false positives, and consistent with the explicit list.char-sinkrespects arity, so a partial application is not a sink, and the string argument is never read as an offset.
Verdict: revise
The rule is in good shape and the revision did real work: three of the four findings turned into code, the fourth into an honest split, and every number in the body reproduces — including the 4-to-8 differential, which I measured against a binary built from the previous head rather than taking the table's word. What is left is small and of a piece with last round: the README makes an unqualified claim about reporting every sink under a binding that one nesting shape falsifies, and the new arithmetic path reports a constant derived from an ASCII literal, which the rule's own exemptions argue it should not. Neither touches a repository in the org today.
Two shapes the second review turned up. A sink nested inside a sink already reported under the same binding was never examined: having reported a node, `mixed-index-uses` did not descend into it, so `(String.prefix (String.suffix s i) i)` reported the outer call only, while the same nesting reached without a binding reported both. The README's unqualified "every offending call under a binding is reported" was the claim that lost. The walk now descends whatever it reported; the double-reporting that guard was avoiding is what `dedupe-sites` is for, and the reachable-both-ways shape still comes out as one finding per site. `(String.prefix s (Int.+ 1 (String.length "ab")))` is `(String.prefix s 3)` written the long way, and was reported. The rule already exempts a literal count and a `Pattern.find` offset on the reasoning that an ASCII literal's byte count and character count are the same number; the arithmetic path was the one place that did not get it. A count built only from integer literals, arithmetic and `String.length` of an all-ASCII literal is now exempt, wherever it reaches the call. The exemption is the constant alone: a non-ASCII literal is still reported, and so is a literal length added to an offset of unknown provenance, which is usually an `index-of` result.
|
Both findings turned into code, and both were reproduced against a binary built from 1 — a sink nested inside a reported sink (code). Reproduced exactly, and it was two shapes, not one: I descended rather than qualifying the README, and measured the thing the guard was presumably there for rather than assuming it. The README sentence stands as written and gained ", including one nested inside another". 2 — I took the exemption but scoped it to the constant, on your note about
That last one is a positive test, along with the Re-validation, all from this revision's binary.
The PR body has the third boundary written up next to the other two, and the test and gate counts updated. |
There was a problem hiding this comment.
Build & Tests
carp -x test/angler.carp at 84e1595 on this armhf Pi — 476 passed, 0
failed, exit code read from the unpiped command, up from the 470 I measured at
7cb8dca. carp -b main.carp: clean, and that binary is what every probe below
runs. carp-fmt --check clean over angler.carp, main.carp,
test/angler.carp and gendocs.carp; carp -x gendocs.carp leaves the tree
clean. CI green on both legs, verified through check-runs at 84e1595 itself.
--list-rules still shows the rule without the [opt-in] marker
leaky-top-level-use carries, so it is on by default. The branch still sits on
bb48b50, origin/main's head, with no release commit in between, so the
## Unreleased entry is filed where it belongs.
Org sweep reproduces exactly: 237 .carp files outside carp_deps, 0
findings. Bumping angler still cannot redden a repository. As a bonus check
against the other PR open this cycle, this binary is also clean over http's
http.carp, test/http.carp and gendocs.carp.
Prior feedback
Both round-2 findings turned into code, and both hold up measured against a
binary I built from 7cb8dca rather than taking the comment's word.
1 — a sink nested inside a reported sink: fixed. My four-shape fixture goes
6 findings -> 8:
7cb8dca 3:5 7:5 11:5 11:20 14:3 14:18
84e1595 3:5 3:20 7:5 7:19 11:5 11:20 14:3 14:18
The two new ones are exactly the inner calls that were swallowed —
(String.suffix s i) inside (String.prefix (String.suffix s i) i) and inside
the String.slice spelling. The reachable-twice shape (11:*) and the
direct-path nesting (14:*) both stay at 2, so the let path and the direct
path now agree on identical source, which was the point.
2 — String.length of an ASCII literal: fixed, and scoped as described. A
ten-shape fixture goes 10 findings -> 4. Silent on this branch:
(Int.+ 1 (String.length "ab")), the bare (String.length "ab"), the
let-bound spelling, nested arithmetic over constants
((Int.+ (Int.* 2 (String.length "ab")) (Int.dec 3))), the same constant in
String.slice's second index, and a literal whose only non-letters are \t\r\n
escapes. Still reported, all four: (Int.+ i (String.length "prefix:")) —
the lit2 shape I asked about — a sum carrying one real index-of,
(String.length "ä"), and a String.slice mixing a constant index with an
index-of one. The exemption is the constant alone, exactly as claimed.
The new recursion is safe on malformed input. (String.length) at zero
arity and (String.length "ab" "cd") at the wrong arity are reported rather
than exempted (a shape the predicate does not recognise is not a constant); a
zero-argument (Int.+), a unary (Int.+ (String.length "")) — correctly exempt,
it is 0 — and an empty binding vector all lint without a crash or a hang.
The descent change did not leak past the two exemptions. A defndynamic
body and a (quote …) form nested inside a reported sink are both still
skipped; only the enclosing sink is reported.
Findings
1. A sink in a rebinding let's own binding vector still reads the outer offset, and is dropped
mixed-index-uses refuses to descend into any node that rebinds the name:
(unless (rebinds? kids name)
(set! acc (extend! acc (mixed-index-uses kids name source 0))))
rebinds? is a property of the whole let, but the scope it describes starts
part way through it. Carp's let is sequential — I checked rather than assumed:
(let [i 7] (let [a (Int.* i 10) i 0 b (Int.* i 10)] …)) gives a=70, b=0 —
so every initialiser before the rebinding pair, and the rebinding pair's own
initialiser, still see the outer name. Skipping the subtree drops them:
(defn a1 [s] ; sink before the rebinding pair
(let [i (String.index-of s \=)]
(let [head (String.prefix s i) ; outer i. not reported
i 0]
head)))
(defn a3 [s] ; the rebinding initialiser is itself a sink
(let [i (String.index-of s \=)]
(let [i (String.length &(String.prefix s i))] ; outer i. not reported
i)))
(defn a5 [s] ; control: rename the second binding
(let [i (String.index-of s \=)]
(let [head (String.prefix s i) ; reported
j 0]
head)))
Only a5 fires. The let-do spelling of a1 is missed too, and the existing
suite's "a rebound name no longer carries the byte offset" pins the body
case ((let [i 0] (String.prefix s i))), which is correctly silent — the
binding-vector sibling is not covered either way.
This is not academic, because the idiomatic way to write a two-field split is to
advance the index by rebinding it, and that is web.carp:591-592 — one of the
eight sites in the differential table — one keystroke away:
(defn split-kv [pair] ; 0 of 2 reported
(let [eq (String.index-of pair \=)]
(let [k (String.prefix pair eq)
eq (Int.inc eq)
v (String.suffix pair eq)]
(String.append &k &v))))
(defn split-kv-control [pair] ; 2 of 2 reported
(let [eq (String.index-of pair \=)]
(let [k (String.prefix pair eq)
v (String.suffix pair (Int.inc eq))]
(String.append &k &v))))
advance.carp:12:13 String.prefix … at: (String.prefix pair eq)
advance.carp:13:13 String.suffix … at: (String.suffix pair (Int.inc eq))
(nothing at all for split-kv)
Same bug, same file, two spellings, and the rule sees one of them.
It predates this revision — the 7cb8dca binary reports the same single
finding on the same fixture, so this round did not introduce it. But it is the
third round running in which the README's unqualified "Every offending call
under a binding is reported, not just the first, including one nested inside
another" is falsified by one shape, and let-bound-mixed-index already has the
machinery: it walks the rest of the outer binding vector with
(code-from bindings (Int.+ ni 2)) for exactly this reason. Descending into a
rebinding let's bindings up to and including the rebinding pair's initialiser,
and stopping there, is the same idea one level down. Qualifying the README
sentence is the other option, and cheaper.
The neighbouring shape — (let [i (Int.inc i)] (String.prefix s i)), advance by
rebinding with the sink in the body — is also silent, and there I think the
current behaviour is defensible: recognising that the new i still carries the
offset needs provenance tracking through a binding, which is a bigger change
than the one above and is arguably what "a name rebound by an inner let no
longer carries the offset" already reserves.
Also checked, nothing found
- The historical differential still stands where it was. Nothing in the
descent or the exemption moved a line. - Nothing in the org changes. The sweep is 0 before and after, so this
finding costs no repository anything today; it is about the claim, not a
regression. dedupe-siteskeys on line and column, so two sinks on one line stay two
findings while one sink reached by both paths stays one — the nested fixture
above shows both halves.
Verdict: revise
The revision did what it said: both round-2 findings became code, the nested
descent measurably takes the fixture from 6 to 8 with the two paths agreeing,
the constant exemption takes ten shapes to four with every "still reported"
promise kept, the new recursion is robust on malformed input, and the org sweep
is still zero. What is left is the same sentence in the README as last round,
one level further in: a sink in a rebinding let's binding vector reads the
outer offset and is dropped, and the shape that reaches it is the idiomatic
advance-by-rebind spelling of a bug this very PR cites. Either descend into
those initialisers or qualify the claim — no other change needed.
`byte-offset-as-char-index` refused to descend into any nested `let` that rebound the tracked name. `rebinds?` is a property of the whole form, but the scope it describes starts part way through it: Carp's `let` binds in sequence, so every initialiser before the rebinding pair, and the rebinding pair's own initialiser, still reads the outer name. Skipping the subtree dropped them, and the shape that reaches them is the idiomatic advance-by-rebind spelling of a two-field split — one keystroke from `web.carp:591-592` in the PR's differential table. The same sequencing was read the other way round in the outer binding vector: a sink in a binding that follows a sibling rebinding the name was reported, although it reads the new name. Both directions now stop one past the rebinding pair's own initialiser, which is what `rebind-cut` computes. An offset carried *through* a rebinding — `(let [i (Int.inc i)] …)` — is still not followed; recognising that needs provenance tracking through a binding. The README claimed the rebinding ended the name's reach for the whole inner form, which was never true of the initialisers before it; it now says where the reach ends and what is not followed.
|
Round-3 finding addressed. I took resolution (a) — descend into a rebinding Before, measuredBuilt a binary from After: 6 findings.
The same sequencing, read the other wayChecking the fix I found the mirror image in the outer binding vector, and it was a false positive rather than a miss:
Tests476 → 483, 0 failed. Seven new: the sink before the pair, the Teeth-checked by reverting Sweep and differentialOrg sweep: 237 The historical differential is unchanged: 11 findings across RobustnessNo crash and no hang on an empty binding vector, an odd-length one ( Checks
The README now says where the rebinding's reach ends, and states plainly that an offset carried through a rebinding is not followed. |
There was a problem hiding this comment.
Build & Tests
carp -x test/angler.carp at 9907ed0 on this armhf Pi — 483 passed, 0
failed, exit code read from the unpiped command, up from the 476 I measured
at 84e1595. carp -b main.carp clean, and that binary is what every probe
below runs. carp-fmt --check clean over angler.carp, main.carp,
test/angler.carp and gendocs.carp, from a binary built fresh at carp-fmt
5e1a550; the new binary lints its own four files clean; carp -x gendocs.carp leaves the tree clean. CI green on both legs. The branch still
sits on bb48b50, origin/main's head, with no release commit in between, so
the ## Unreleased entry is filed where it belongs.
All three validation claims reproduce to the number, measured against a
second binary built from 84e1595 rather than remembered:
- Org sweep: 237
.carpfiles outsidecarp_deps, 0 findings. - Historical differential: 11, unchanged between the two binaries —
time@b9dec03^3,time@f15591c^3,web@cce9ed5^5. - Nothing else in the org moves: running both binaries over the same 237
files with the full default rule set gives the same 169 diagnostics, the
same set line for line. (Only the ordering differs between runs, which is
xargsbatching, not the rule.)
As a cross-check against the other three PRs open this cycle, this binary is
also clean over llm, http and http-client at their branch heads, so
bumping angler cannot redden them either.
Prior feedback
The round-3 finding turned into code, and it holds up measured against the
84e1595 binary:
a1, the sink before the rebinding pair: silent -> reporteda3, the rebinding pair's own initialiser: silent -> reportedsplit-kv: 0 of 2 -> 1 of 2, at(String.prefix pair eq)a5and the non-rebinding control: unchanged, still 1 and 2
And the 1-of-2 is the honest number, not a shortfall dressed up: the second
sink reads the new eq, which is the provenance shape I explicitly scoped
out, and the README now says so rather than promising reach it does not have.
The new descent is safe. Twelve malformed and adversarial shapes lint
without a crash or a hang: an empty inner binding vector, an odd-length one, a
bare (let), a let with no body, a comment sitting before the rebinding pair
(the cut does not shift — code-from maps a code index to a raw one), a -
discard as the rebinding name (correctly not a rebinding, so the initialiser
after it is still read), two rebindings in a row, (let [i i] ...), the
rebinding pair last in the vector, a vector with no body form, three nested
rebinding levels (exactly 1 finding, at the outermost), and 400 nested lets.
I also re-confirmed the sequencing by running it rather than assuming: (let [i 7] (let [a (Int.* i 10) i 0 b (Int.* i 10)] (Int.+ a b))) gives 70.
Findings
1. The other rebind-cut call site guards a shape Carp will not compile
rebind-cut is used twice. The one in mixed-index-uses — descending into a
nested let's bindings and stopping one past the rebinding pair — is the fix,
and it is real: that is the split-kv path and the four shapes above.
The other, at angler.carp:1900, cuts the walk of the vector the source pair
itself lives in, at a later pair in that same vector that rebinds the name.
That is a duplicate binding inside one let, and Carp rejects it outright:
$ cat t.carp
(defn f [s] (let [i 1 i 2] i))
$ carp -x t.carp
I encountered a duplicate binding `i` inside the `let` at line 1, column 23 ...
Same for let-do, and it fires even when the second binding is never read
((let [i 1 i 2] 9) is rejected too). So the fixture in the follow-up comment —
(let [i (String.index-of s \=) i 0 k (String.prefix s i)] k)
— is a false positive only on a file the compiler already refuses, which means
that half of the change cannot alter the verdict on any file that compiles.
The test that pins it, test/angler.carp:756-760 "a sibling that rebinds the
name ends its reach in the same vector", asserts on that same non-compiling
fixture; its control at :751-755, "a later binding in the same vector carries
the byte offset", is fine and reachable.
Nothing is broken by this — a linter reads text, not compiled code, and staying
sane on a file that does not compile is a defensible thing to do. What wants
correcting is the framing: "the same sequencing, read the other way, was a
false positive nobody had noticed" claims a class of real reports removed, and
what was removed is a report on source carp will not accept. The README, for
what it is worth, does not inherit the problem — "an initialiser earlier in
the same vector, and the rebinding pair's own initialiser, still read the outer
name" is true wherever it can apply, which is the nested case.
No code change needed; this is a note for the record and, if you want it, one
test label that says what the fixture actually is.
Also checked, nothing found
- The cut is the right one in the reachable direction. A sink after the
rebinding pair stays silent, in the vector and in the body, at one level and
at three; a rebinding as the last pair still lets the sink before it fire;
the rebinding pair's own initialiser fires and nothing past it does. - The
let-dospelling behaves identically toletin both directions. - The descent did not leak past the two exemptions — a
defndynamicbody
and a(quote ...)form nested inside a rebindingletare still skipped. - The finding costs the org nothing either way: the sweep is 0 before and
after, and the full 169-diagnostic output is identical between the binaries.
Verdict: merge
Four rounds in, every finding I raised has become code or an honest correction,
and this round is no exception: both halves of the rebinding fix are measured
against a binary built from the previous head, the suite is 476 -> 483 with five
of the seven new cases failing against that old binary, twelve malformed shapes
are safe, the org sweep is still zero and the whole-org diagnostic set is
unchanged. What is left is one sentence of framing in a PR comment, not a
change to the repository — the guard it describes is inert rather than wrong,
because the shape it fires on does not compile.
| - New rule `byte-offset-as-char-index`: a byte offset from | ||
| `String.length`, `String.index-of` or `String.index-of-string` passed | ||
| where `String.prefix`, `String.suffix` or `String.slice` wants a | ||
| character index — directly, through a `let` binding, or with integer | ||
| arithmetic applied on the way. Mixing the two index spaces returns the | ||
| wrong text as soon as one non-ASCII character precedes the offset, and | ||
| an offset past the character count aborts the process. It is reported | ||
| only; the fix is usually `String.byte-slice`, and sometimes a redesign. | ||
| Only the `String.`-qualified spelling is matched, because `Array` has | ||
| same-arity counterparts for all six names. An offset from | ||
| `Pattern.find` is deliberately not reported, and neither is a count | ||
| that is constant by construction, such as `String.length` of an | ||
| all-ASCII string literal. |
There was a problem hiding this comment.
that’s an essay, not a changelog entry.
There was a problem hiding this comment.
Cut to six lines in aef77dd — what fires, report-only, the fix.
| `slice` at the same arities, so a bare name in a `use Array` file would | ||
| be reported under a `String.` name it never called — and a finding that | ||
| names the wrong function is worse than one that misses the `use String` | ||
| call site. |
There was a problem hiding this comment.
this is a readme, not your personal logorrheia dumping ground.
There was a problem hiding this comment.
Cut to one 14-line paragraph in aef77dd; the design arguments are gone.
Both argued for the design rather than describing the behaviour. They now say what fires, why the two index spaces cannot be mixed, that the fix is usually String.byte-slice, that the rule is report-only, and that only the String.-qualified spelling matches; the exemptions are one clause each. Documentation only, no rule behaviour changed.
|
@hellerve — both files cut in CHANGELOG (13 lines → 6, in line with the README (56 lines / five paragraphs → 14 lines / one paragraph, the range the other rule blurbs sit in): what fires and why the two index spaces cannot be mixed, then one clause listing the exemptions so nobody is surprised by silence. The paragraphs arguing why Verified at |
There was a problem hiding this comment.
Build & Tests
carp -x test/angler.carp at aef77dd on this armhf Pi — 483 passed, 0
failed, exit code read from the unpiped command. Unchanged from 9907ed0, as
it has to be: git diff 9907ed0 aef77dd touches CHANGELOG.md and README.md
and nothing else, so angler.carp and test/angler.carp are byte-identical to
the revision I reviewed last round. carp -b main.carp clean, and that binary
is what every probe below runs. CI green on both legs. The branch still sits on
bb48b50, origin/main's head, with no release commit in between, so the
## Unreleased entry is still filed where it belongs. carp-fmt --check was
not re-run and does not apply — no .carp file changed this round.
Org sweep, from a binary built at this head: 237 .carp files across 47
clones outside carp_deps, 0 findings. json and llm were checked out at
their open PRs' branch heads while that ran, so the three PRs open this cycle
still do not collide.
Prior feedback
Both of @hellerve's inline comments are answered, and each cut lands exactly
where the comment pointed — CHANGELOG.md:20 was inside the byte-offset bullet
and README.md:201 was the last line of its fifth paragraph.
The CHANGELOG bullet: 13 lines to 6. Measured against its neighbours rather
than guessed: the other three bullets under ## Unreleased are 4, 5 and 6
lines, so this one is now exactly as long as the longest of them, where it used
to be more than double. What survives is what a reader acts on — what fires, that only the String.-qualified spelling
matches, that it is report-only, that the fix is usually String.byte-slice.
The README section: 56 lines and five paragraphs to one paragraph of 14.
The other rule blurbs in that section run 4, 6, 6, 7, 8, 8, 8, 8, 11, 12 and 14
lines. At 14 this one ties unused-let-binding for the longest single
paragraph and sits inside the range; unused-defn-parameter (7 + 11 + 8) and discarded-let-body
(8 + 8) still spend more lines in total. It went from far and away the largest
section in the file to an unremarkable one.
The cut did not orphan anything: byte-offset-as-char-index is still in the
report-only list at README.md:57-68.
My round-4 note asked for no code change, and none was made. Correct.
Findings
I ran the surviving README paragraph as a specification, against a binary built
from this head, on a fixture written from the sentence rather than from the test
suite. Every clause it still makes holds:
- fires —
String.length,String.index-ofandString.index-of-string
intoString.prefix, intoString.suffix, into either index of
String.slice; through alet-bound name; withInt.incon the way. 7 of 7. - silent — the bare unqualified spelling, a literal count, a count constant
by construction ((Int.+ 1 (String.length "ab"))), aPattern.findoffset,
adefndynamicbody, adefmacrobody. 6 of 6.
1. The blurb drops let-do, against the file's own convention (nit)
README.md:158 now reads "a name the enclosing let bound to one". The rule
does fire on let-do — I checked at this head:
readme_fixture.carp:10:51: [byte-offset-as-char-index] String.prefix counts
characters but String.index-of answers in bytes ...
at: (String.prefix s i)
(that line is (defn s6 [s] (let-do [i (String.index-of s \=)] (String.prefix s i))))
Every other place in these two files that talks about this spells out both:
README.md:75 is "a let or let-do binding", and so are the
unused-let-binding and shadowed-let-binding entries in CHANGELOG.md. The
previous revision of this paragraph said "the enclosing let or let-do"
too. Two words, and it puts the sentence back in line with the four other rules
that answer the same question. Same in the CHANGELOG bullet, which now says
only "through a let binding".
Entirely optional, and not worth a round on its own.
Also checked, nothing found
- The two silences the paragraph no longer mentions are silences, not
reports, so the shorter text does not promise anything the rule fails to
deliver: an offset carried through a rebinding
((let [i (String.index-of s \=)] (let [i (Int.inc i)] (String.prefix s i))))
is quiet, as before. - The sibling-rebind report I raised in round 4 is still there and still
inert:(let [i (String.index-of s \=) i 0] (String.prefix s i))is
reported at22:52, andcarprejects both that and itslet-dospelling
outright — "I encountered a duplicate bindingiinside thelet" — so it
cannot change the verdict on a file that compiles. With the reach paragraph
gone from the README, there is no longer a documented claim for it to
contradict either. - The report-only rationale line still names the rule and still says the
right thing about it ("has no unconditionally correct rewrite"). - No behaviour moved, and it could not have:
angler.carpataef77ddis
identical toangler.carpat9907ed0.
Verdict: merge
Five rounds in, this one is the maintainer's two comments turned into two cuts,
both of them calibrated against the file they live in rather than against a
feeling, with the code untouched and the suite still at 483/0. The one thing I
would still change is two words, and it is not worth holding the PR for.
Core's
Stringmixes two index spaces, and nothing in the toolchain notices when they are crossed.String.lengthisstrlen;String.index-of,String.index-of-string,String.char-atandString.byte-sliceare all byte-based.String.prefix,String.suffix,String.sliceandString.charsgo through the UTF-8-decoded character array. Feed an offset from the first group into the second and the slice lands in the wrong place as soon as one non-ASCII character precedes it; run the offset past the character count andArray.slice'sunsafe-nthaborts the process.This class has produced a string of merged fixes across the org — angler #23, web #52 (four reachable sites; any unauthenticated request could SIGABRT the server), time #22 (
Datetime.formatfedindex-of's byte offset toprefix, so(fmt "Groesse: %Y" &dt)printed the format string back), time #23, diff #13, mustache #16, path #13 — and every one was found by a person reading code.The rule
byte-offset-as-char-indexfires on the count argument ofString.prefixandString.suffixand on either index ofString.slice, when that argument isString.length,String.index-oforString.index-of-string,let/let-dobound to one of those calls, or(String.prefix s (Int.dec i))cuts in the same wrong space as(String.prefix s i).Every offending call under a binding is reported, not just the first and including one nested inside another, and a name rebound by an inner
letstops carrying the offset.Only the
String.-qualified spelling is matched, on the sink and on the source.Arrayhaslength,index-of,prefix,suffixandsliceat the same arities, so a bare name in ause Arrayfile would be reported under aString.name it never called. That is the same trade the source side already made for a barelength; no file in the org doesuse String, and five useArray.Reported only, no
--fix: the right answer is usuallyString.byte-sliceand sometimes a redesign of the scan, so no rewrite is unconditionally semantics-preserving. angler already has that category and the README explains it.Three deliberate boundaries
Pattern.findoffsets are not reported. That is a byte offset too, but a pattern that can only match ASCII makes the byte and character offsets coincide, and that is what the calls in the wild look like —semver.carp:25is exactly that safe-by-construction case, and reporting it would turn a currently-green repository red for no defect. The boundary is part of the design: the rule reports the sources that are wrong whatever the pattern and leaves the one whose safety depends on it. It is stated in the rule's README entry and in the CHANGELOG.A count that is constant by construction is not reported.
(String.prefix s (Int.+ 1 (String.length "ab")))is(String.prefix s 3)written the long way: for an all-ASCII literal the byte count and the character count are the same number, so the arithmetic never leaves the character space. That is the same reasoning the literal-count andPattern.findboundaries already rest on. The exemption is the constant alone — a literal with a non-ASCII byte in it still counts more bytes than characters and is reported, and so is a literal length added to an offset the rule cannot see the provenance of, which is usually anindex-ofresult.defndynamicanddefmacrobodies are exempt. They run in the compiler, whereString.prefixandString.index-ofare dynamic builtins over the evaluator's own strings rather than the core functions this rule is about. This is not theoretical:redis.carp:592is adefndynamicwith exactly the reported shape — two sinks, one of them behind(+ i 1)— and it was the only finding in the whole org before the exemption.unused-defn-parameterleavesdefndynamicanddefmacroout for the same reason.It is registered as a file rule rather than a node rule so it can skip those subtrees — a node rule cannot see what encloses it — and so it can report every offending site in a file at that site's own line, including the
let-bound case, where the finding points at the call rather than at the binding.Validation 1 — it reddens nothing today
Built from this branch and run over every
.carpfile under~/carpentryexcludingcarp_deps:Zero findings, so bumping angler cannot break a repository in the org.
Validation 2 — which half of the class it catches
The seven merged fixes above split in two, and the rule reaches one half. Pre-fix blobs pulled straight out of git history (
git show <fix>^:<file>) and linted:The call-site half — caught. Eight sites, and
git log -Sconfirms each was removed by the fix commit that closed the bug:time.carp:599(let [idx (String.index-of s \%)] … (String.prefix s idx))b9dec03— PR #22,Datetime.formattime.carp:601… (String.suffix s (+ idx 2)), same bindingb9dec03— PR #22time.carp:631(let [clen (String.length candidate)] … (String.prefix &sub clen))f15591c— PR #23, the strptime scannerweb.carp:591(let [eq (String.index-of pair \=)] … (String.prefix pair eq))cce9ed5— PR #52web.carp:592… (String.suffix pair (+ eq 1)), same bindingcce9ed5— PR #52web.carp:1547(let [dash (String.index-of rest \-)] … (String.prefix rest dash))cce9ed5— PR #52web.carp:1548… (String.suffix rest (+ dash 1)), same bindingcce9ed5— PR #52web.carp:3018(String.prefix &path (- (String.length &path) 1)), trimming a trailing/off a request pathcce9ed5— PR #52Four of those eight need this revision to fire: the three
(+ i 1)siblings need both the arithmetic match and the every-sink-per-binding change, andweb.carp:3018applies its arithmetic toString.lengthdirectly.The core-internal half — out of reach by construction. angler #23, mustache #16, diff #13's
parse-hunk-startand path #13'sabsolute?are one sub-class: the index spaces are mixed inside core'sString.starts-with?/String.ends-with?, and the call site reads(String.starts-with? tok "-"), which is not aprefix/suffix/slicesink at all. All four pre-fix blobs —angler@ee23a8c^,mustache@fad7d4c^,diff@ca59038^,path@31ecbe2^— lint clean under this branch's binary. Two of them have a second reason as well: diff #13 also fixed(String.suffix tok 1), a literal count the rule deliberately treats as safe, and path #13'ssplit-extensiontook its offset fromPattern.find, the source the rule deliberately leaves alone.So the rule covers the call-site half of the class. The core-internal half still needs a person — or a second rule aimed at the core predicates, which is the natural follow-up.
Every clone was read through
git showand none was checked out, so all working trees are untouched.Also
test/angler.carp, both directions: the shapes that must fire (direct call,let-bound,let-do, nested in the body, second index ofslice, arithmetic in three positions, two sinks under one binding, a sink nested inside a reported one, an all-but-ASCII literal's length, two sites in one file) and the shapes that must not (a literal count,String.charswith no index, aPattern.findoffset, a byte-space-onlyString.length+String.byte-sliceloop,Array.prefix, bareprefix/slice/index-of/length, arithmetic on a character count, a constant written the long way, an ASCII literal's length through a binding,defndynamic,defmacro, quoted data, a rebound name), plus four counting tests pinning that every sink under one binding is reported, that a sink nested inside a reported sink is reported too, and that a sink reachable twice — nested or not — is one finding.## Unreleased.Gates:
carp -x test/angler.carp476 passed / 0 failed;anglerbuilt from this branch and run overangler.carp,main.carp,test/angler.carpandgendocs.carp: clean;carp-fmt --checkclean;carp -x gendocs.carpleaves a clean tree.Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.