Skip to content

chore(lints): gate span arithmetic, lexer indexing, and production unwrap - #1229

Merged
dekobon merged 10 commits into
mainfrom
fix/1152-1227-panic-free-lints
Aug 7, 2026
Merged

chore(lints): gate span arithmetic, lexer indexing, and production unwrap#1229
dekobon merged 10 commits into
mainfrom
fix/1152-1227-panic-free-lints

Conversation

@dekobon

@dekobon dekobon commented Aug 7, 2026

Copy link
Copy Markdown
Owner

Closes #1152. Closes #1227.

Two clippy lints adopted on the paths that handle attacker-controlled input,
each validated by replaying it against the tree that carried the bug it
claims to catch
, plus removal of the fifteen .expect(FEATURES_PINNED) call
sites the CLI and web crates used to discharge a widened Result.

Validation, not argument

#1152 asked for this explicitly: "A lint adopted on the strength of an
argument and then drowned in #[allow]s is worse than no lint, because it
reads as coverage."
Both adoptions replay a real historical bug.

Lint Replayed at Result
arithmetic_side_effects 92680fa8^ (pre-#1051) flags both panic sites: loc/rust.rs:48 (end - 1) and loc/shared.rs:46 (end - start)
indexing_slicing f59c5c06^ (pre-#126) flags both &DOLLARS[..(i - start)] slices
unwrap_used n/a — 0 production sites free to adopt; fails CI on the first one added

The issue was wrong about the second one. #1152 rated indexing_slicing
"plausible but unproven; ~20 raw indexes in c_macro.rs, none of which has
produced a bug
." But #126"DOLLARS buffer overflow panics on macro
identifiers longer than 2048 bytes"
— was a slicing panic in that exact
file, reachable from user source plus a user-supplied macro set. It was adopted
on replayable evidence rather than the weaker prevention footing the issue
assumed.

Scope was decided by measurement

arithmetic_side_effects on the issue's suggested src/metrics/ gives 313
hits. The distribution is what decides it:

Operator Count
+= (counter increments) 267
binary + 29
binary - 15
binary * 2

All 15 subtractions are in src/metrics/loc/, and loc is the only module in
src/metrics/ that computes on tree-sitter span coordinates rather than its
own counters. The other 244 hits are not this bug class and would bury it.

Total #[allow] count across both lints: 10, each naming its invariant —
nine per-function in c_macro.rs (so a newly added function is covered by
default, verified with a throwaway unguarded index) and one file-level in
line_set.rs. Zero in the loc span code: 36 logical_lines += 1 collapsed
into Lloc::count_logical_line, the rest made explicitly saturating.

The line_set.rs carve-out is deliberate and is the one worth a reviewer's
attention: its arithmetic is on bitset word indices, where saturating
self.words[word - self.first_word] to index 0 would read the wrong row and
silently miscount, whereas the current form panics on a corrupt offset — the
way intersection_len's existing comment says it is meant to fail. Prevention
and masking point in opposite directions there.

unwrap_used is not a [workspace.lints] entry

0 production unwrap(), 37 production expect() — measured over lib and
bin targets so #[cfg(test)] modules are excluded by construction. Across
--all-targets the same measurement is 1,023 unwrap() and 2,157
expect()
, essentially all legitimate test code, so a Cargo lint (which
applies to every target of its package) was not an option.

It is #![cfg_attr(not(test), warn(clippy::unwrap_used))] at each of the ten
production crate roots. The load-bearing fact — checked rather than assumed,
because the widely-repeated claim is the opposite — is that cfg(test) is
set for integration-test crates
, not just the unit-test target. One form
therefore covers #[cfg(test)] modules and tests/*/main.rs alike: no
per-file carve-out, no footgun for the next integration test added, and zero
#[allow]s
.

expect_used is declined. All 37 sites already name their invariant in the
message, the form AGENTS.md sanctions, so gating it buys 37 annotations that
restate the line above them. The distinction that decides it is not
expect-vs-unwrap but what can invalidate the invariant: the
FEATURES_PINNED sites rested on a #[non_exhaustive] enum a dependency
bump
could change underneath them; these 37 rest on facts local to this
repository. That reasoning lives in [workspace.lints.clippy], where the next
person will look for the absent lint.

FEATURES_PINNED — wider than the issue scoped

#1152 named dispatch.rs. big-code-analysis-web/.../handlers.rs carried the
identical latent panic at 7 more sites, so all 15 are gone. Both crates already
had an error channel, which is the tell the expect was never necessary: the
CLI maps to io::Error(InvalidData) that the concurrent runner reports per file
and continues past; the web folds into the existing sanitized 500, which is
the same status unwinding through spawn_blocking already produced — what
changes is that the log names the real cause and no worker thread unwinds.

Lessons-learned #26, which recommended the .expect(FEATURES_PINNED) pattern,
is corrected in the same change.

Behaviour and risk

  • Metric values are unchanged. A saturating operation equals the plain one
    unless it would have overflowed, and none does. No snapshots move.
  • The enums codegen changes are verified by the existing
    utils/check-enums-codegen-drift.sh, which regenerates in both rust and
    c_macros modes and diffs against the checked-in output — c_macros is the
    mode using the CARGO_MANIFEST_DIR path that changed. Byte-identical.
  • dispatch_find and dispatch_exemptions each gain one nexits — the ?
    that replaced the expect — tripping the soft tier at 4.75. Both are
    baselined in the same commit per the refresh discipline; the hard limit of 5
    is unchanged and still met. This is the ?-is-an-exit artifact AGENTS.md
    calls out, not new branching.

Tests and coverage

Both new error paths are unreachable end-to-end by the feature pin, so they are
pinned by unit tests on the mapping rather than by a vacuous end-to-end test.
Every mutation against them is caught — wrong ErrorKind, cause stringified
instead of carried, log line deleted, wrong ParseError variant — each
confirmed to fail on an assertion rather than a compile error.

Patch coverage cannot drop: outside enums/** and xtask/** (both in
codecov.yml's ignore list as code-generation helpers), the lint-gate diff
is 30 lines that are entirely comments and the #![cfg_attr(...)] attribute —
zero coverable lines, measured.

Reviewer notes

  • make pre-commit is green.
  • Four rounds of quality skills ran over the branch (simplify-rust,
    rust-optimize, review, audit-tests) plus a final /code-review. Their
    findings are folded in; several were corrections to my own over-claiming
    comments, which is why four commits here are prose-only.
  • Commit messages in this branch say "sixteen call sites"; the correct figure
    is fifteen plus two constants. CHANGELOG.md and lessons-learned are
    corrected; the commit messages are left as history.
  • chore(lints): enums is CI-linted without the workspace pedantic set #1228 is filed as a follow-up: enums is CI-linted with -D warnings
    but without the workspace pedantic set, since refactor(workspace): propagate pedantic clippy + missing_docs lints (carved out of #150) #158 scoped propagation to
    "the three shipping crates". 23 pedantic warnings, all pre-existing,
    deliberately not bundled here.

dekobon added 9 commits August 7, 2026 09:24
Adopt two clippy lints on the paths that handle attacker-controlled
input, each validated by replaying it against the tree that carried the
bug it claims to catch, and drop the sixteen `expect(FEATURES_PINNED)`
call sites the CLI and web crates used to discharge a widened `Result`.

`clippy::arithmetic_side_effects` on the `loc` metric module. Replayed
at 92680fa^ it flags both of #1051's reported panic sites — the
`end - 1` in `loc/rust.rs` and the `end - start` in `add_cloc_lines`.
Scoped to `loc` rather than `src/metrics/`: the other metrics
contribute 244 hits, all `+=` on their own counters, which is not this
bug class and would bury it. The 69 in-scope hits resolve to zero
`#[allow]`s in the span code — 36 `logical_lines += 1` collapse into
`Lloc::count_logical_line`, and the rest become saturating — plus one
file-level carve-out in `line_set.rs`, where the arithmetic is on word
indices and saturating would read the wrong row rather than prevent
anything.

`clippy::indexing_slicing` on `src/c_macro.rs`. The issue rated this
unproven; it is not. #126 was `&DOLLARS[..(i - start)]` panicking on a
2049-byte macro identifier, and replayed at f59c5c0^ the lint flags
both slices. Nine per-function carve-outs, never file-wide, so a new
function is covered by default. `step_raw_string`'s delimiter
comparison is hardened with `get` instead of allowed: its bound is
established in `enter_raw_string` and carried through `LexState`, which
is the #126 shape and the one a reader cannot check locally.

`expect(FEATURES_PINNED)` was sound only while `MetricsError` had no
variant it did not handle, and that enum is `#[non_exhaustive]` with a
doc reserving the right to add variants in a minor release — a panic
scheduled against a routine dependency bump. Both crates already had an
error channel: the CLI's helpers return `io::Result`, whose runner
prints a per-file line and continues, and the web handlers have an
existing sanitized 500. Neither needed a new failure mode, only for the
existing one to be used. No reachable behaviour change today, so both
error paths are pinned by unit tests on the mapping rather than
end-to-end.

Metric values are unchanged: a saturating operation equals the plain one
unless it would have overflowed, and none does.

`dispatch_find` and `dispatch_exemptions` gain one `nexits` each — the
`?` that replaced the `expect` — tripping the soft tier at 4.75. Both
are baselined; the hard limit of 5 is unchanged and still met.

Fixes #1152
The seven handlers each repeated `.await?.map_err(ParseError::from_metrics)?`
after dropping their `expect(FEATURES_PINNED)`. That is seven chances to
reach for `expect` again instead of propagating, which is the thing #1152
was cleaning up. One wrapper leaves each handler with a single `?`.

The three VCS handlers keep plain `run_parse`: their closures do not
return `MetricsError`.
Its callers are the per-language `loc/*.rs` modules, which are
descendants of `loc` and so can already see a private item — the same
reason they could write to the private `logical_lines` field before.
`pub(crate)` claimed a wider surface than the helper has.
Both claimed more than the change buys, found in review.

`add_cloc_lines`: `comment_diff` feeds only the two branch tests, never
a row index, so both branches already hand `add_only_comment_lines` the
raw span and `insert_range` already rejects an inversion. The
`saturating_sub` changes which branch runs, not whether the rejection
happens.

`span_rows`: `sloc()` already clamps with `saturating_sub`, so a wrapped
`excluded_lines` could never have escaped as `usize::MAX`. It would have
escaped as `sloc: 0` for a non-empty file.

Same class as the follow-up the #1051 fix itself needed on this
function.
Triage of the 37 production `unwrap`/`expect` sites #1227 asked for,
plus the one gate that triage justifies.

The count is the finding: **0 production `unwrap()`, 37 production
`expect()`**, measured with the lints enabled over lib and bin targets
so `#[cfg(test)]` modules are excluded by construction rather than by a
grep heuristic. `unwrap_used` therefore costs nothing to adopt today and
fails CI on the first one added.

It is set per crate root as `#![cfg_attr(not(test), warn(...))]`, not in
`[workspace.lints]`, because a Cargo lint applies to every target of its
package: the same measurement across `--all-targets` reports 1_023
`unwrap()` and 2_157 `expect()`, all of them legitimate test code.
`cfg(test)` is set for integration-test crates as well as for the
unit-test target, so the per-root form needs no per-file carve-out and
carries zero `#[allow]`s. Verified in both directions with a
cache-busted probe: a production `unwrap` is flagged, the same `unwrap`
in a `#[cfg(test)]` module is not.

`expect_used` is declined. All 37 sites already name their invariant in
the message, which is the form `AGENTS.md` sanctions, so gating it buys
37 annotations that each restate the line above them — the "drowned in
allows, reads as coverage" outcome #1152 was avoiding.

The distinction that decides it is not `expect`-vs-`unwrap` but what can
invalidate the invariant. The `FEATURES_PINNED` sites #1152 removed
rested on a `#[non_exhaustive]` enum a dependency bump could change
underneath them. These 37 rest on facts local to this repository — a
constant regex compiles, a walker pushed a root before descending, a
`strip_prefix` follows a `starts_with` three lines up — which review and
tests already cover.

No test accompanies the gate: the only shape available would grep this
repository's own source for the attribute, which `.claude/rules/testing.md`
bans as vacuous. The lint firing in CI is the test.

Fixes #1227
#1227's gate stopped at the cargo workspace, and `enums` is not in it.
The crate is excluded from `[workspace] members` for build reasons but
is still first-party Rust, still CI-linted (`make enums-check` runs
clippy with `-D warnings` and its tests), and held 7 production
`unwrap()` calls that `cargo clippy --workspace` never saw. So the
"0 production `unwrap()`" figure was true of the workspace and not of
the repository, which is the reading the gate invites.

One of the 7 was a live latent panic rather than a style issue: the Go
generator's `names.iter().map(|x| x.0.len()).max().unwrap()` panics on a
grammar that contributes no token names. It becomes `unwrap_or(0)`,
which is the identity here — with no names the `map` below yields
nothing, so the padding width is never read.

The rest propagate onto the `io::Result<()>` every generator already
returned, via a shared `render_error` lift for the `askama` failures.
`env::var("CARGO_MANIFEST_DIR").unwrap()` becomes `env!(...)`: the
compile-time macro is both infallible and more correct here, since it
resolves the enums crate's own source tree rather than whatever
environment the binary is invoked from.

Both `enums` roots (lib and bin) now carry the same
`#![cfg_attr(not(test), warn(clippy::unwrap_used))]` as the eight
workspace roots, so the gate's claim now matches its scope.

Verified by the existing codegen-drift gate rather than by assertion:
`utils/check-enums-codegen-drift.sh` regenerates in both `rust` and
`c_macros` modes and diffs against the checked-in output. `c_macros` is
the mode that goes through the `CARGO_MANIFEST_DIR` path, and the
output is byte-identical.

The new `render_error` lift carries a unit test; the empty-`names` case
has no reachable input through any real grammar, so `unwrap_or(0)` is
correct-by-construction rather than a fixed live bug with a regression
test.

Follow-up to 8cf09bc (#1227).
The scripted insertion in 7ba15b1 placed the attribute between the
first `//!` line and the rest of the crate doc in two files. In
`big-code-analysis-cli/src/main.rs` that split a sentence across it.
rustdoc concatenates the blocks so the rendered output was unaffected,
and rustfmt does not move attributes, so neither gate could see it.

Also drops `use std::env;`, left dead by the `env::var` -> `env!`
change. rustc does not warn: the `env!` macro path marks the name used,
which is why `-D warnings` stayed green. Verified by compiling without
the import.

Both moved attributes are still in effect — a probe `unwrap()` in
`main.rs` is flagged.
Found in review. The changelog and the go.rs comment described the
`max().unwrap()` conversion as fixing a live latent panic. It is not
reachable: `get_token_names` walks `0..node_kind_count()` and every real
tree-sitter grammar has at least the ERROR sentinel, so `names` is never
empty and `.max()` is never `None`.

The conversion is still right, for the reason #1227 rests on rather than
the one claimed: an `unwrap()` states no invariant, which is what
separates it from the 37 `expect` sites left alone. Both now say that
instead.

Third time this session that a guard comment claimed more than the
change buys.
All comment and prose only; no behaviour change.

`run_parse_fallible` was inserted between `run_parse`'s doc block and
`run_parse` itself, so the block documented the wrong function and left
`run_parse` undocumented. This is the third time in this branch that an
insertion-based edit landed between a doc comment and its item — the
other two were the crate-root attributes fixed in 7f8efe5. Neither
rustfmt nor clippy can see this class, and `missing_docs` does not fire
because `run_parse` is private.

"Sixteen `.expect(FEATURES_PINNED)` call sites" is fifteen: 8 in the CLI
dispatch helpers and 7 in the web handlers, plus the two constants,
which the original wording folded into the site count. Corrected in
CHANGELOG.md and lessons-learned; the commit messages in this branch
still say sixteen and are left as history.

`dispatch_dump` still carried "the `expect` documents that invariant"
above a line that now propagates with `?`.

The `add_cloc_lines` rationale, itself already corrected once in
a0925a7, was still imprecise: the `== 0` arm does not call
`add_only_comment_lines` at all, and the `> 0` arm passes `start + 1`
rather than the raw span. It now says what the `saturating_sub` actually
buys — a truthful branch classification, not an observable metric
change.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.35%. Comparing base (a47d020) to head (debd153).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1229      +/-   ##
==========================================
+ Coverage   98.33%   98.35%   +0.01%     
==========================================
  Files         278      278              
  Lines       72043    72068      +25     
  Branches    71613    71638      +25     
==========================================
+ Hits        70845    70879      +34     
+ Misses        788      779       -9     
  Partials      410      410              
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.34% <100.00%> (+0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/c_macro.rs 98.11% <100.00%> (-0.01%) ⬇️
src/metrics/loc.rs 99.92% <100.00%> (+<0.01%) ⬆️
src/metrics/loc/bash.rs 100.00% <100.00%> (ø)
src/metrics/loc/c.rs 90.32% <100.00%> (+9.67%) ⬆️
src/metrics/loc/cpp.rs 100.00% <100.00%> (ø)
src/metrics/loc/csharp.rs 96.15% <100.00%> (ø)
src/metrics/loc/elixir.rs 100.00% <100.00%> (ø)
src/metrics/loc/go.rs 100.00% <100.00%> (ø)
src/metrics/loc/groovy.rs 90.32% <100.00%> (ø)
src/metrics/loc/irules.rs 95.23% <100.00%> (ø)
... and 17 more
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Codecov reported 95.52% patch coverage against 98.33% project, with
three missing lines: the `PreprocArg` range-insert in `loc/c.rs`,
`loc/mozcpp.rs` and `loc/objc.rs`. C++'s identical copy was already
covered, which is the per-language blind spot `AGENTS.md` warns about —
these four modules are deliberate clones, so one passing language said
nothing about the other three.

A backslash-continued `#define` body parses as one `PreprocArg` node
spanning every continuation row, and each of those rows is PLOC because
`tree-sitter-cpp` does not expand macros. The fixture is one macro
across three rows plus a `main`, swept over all four languages through
`metrics_verbatim`, which takes a `LANG` — the only way to reach
`Mozcpp`, which owns no file extension.

Values are measured, not guessed, and the test is confirmed
discriminating per language: deleting the arm from each module in turn
fails it, each time compiling first so the failure is the assertion and
not the build. `ploc` is 4 with the arm and 3 without.

This closes the patch-coverage gap; the three lines were the only ones
in the PR that any test could reach. My earlier "zero coverable lines"
claim was scoped to the lint-gate commits alone, not the whole PR.
@dekobon

dekobon commented Aug 7, 2026

Copy link
Copy Markdown
Owner Author

Patch coverage: the three missing lines are covered

Codecov reported 95.52% patch vs 98.33% project, with three missing lines —
the PreprocArg range-insert in loc/c.rs, loc/mozcpp.rs and loc/objc.rs
(each file 66.66%: two count_logical_line lines covered, the range line not).

C++'s identical copy of that arm was already covered. That is exactly the
per-language blind spot AGENTS.md warns about — the four C-family Loc
modules are deliberate clones, so one passing language said nothing about the
other three.

debd1538 adds a_continued_macro_body_counts_every_row_it_spans. A
backslash-continued #define body parses as a single PreprocArg node
spanning every continuation row, and each row is PLOC because
tree-sitter-cpp does not expand macros. The fixture sweeps all four
languages through metrics_verbatim, which takes a LANG — the only way to
reach Mozcpp, which owns no file extension and so gets no
integration-snapshot coverage at all.

Values are measured rather than guessed, and the test is confirmed
discriminating per language: deleting the arm from each module in turn
fails it, each perturbation confirmed to compile first so the failure is the
assertion and not the build.

Module arm deleted result
loc/c.rs ploc 4 → 3 caught
loc/mozcpp.rs ploc 4 → 3 caught
loc/objc.rs ploc 4 → 3 caught
loc/cpp.rs ploc 4 → 3 caught

That is stronger than the coverage number asks for: a line can be executed
without being discriminated, and these are both.

Correcting my own earlier claim

I wrote in the PR body that the change has "zero coverable lines". That was
measured over the lint-gate commits only (a0925a7f..HEAD), not the whole PR
— the #1152 half adds real executable code. The accurate statement is the one
above: three coverable lines were uncovered, and they now are not.

@dekobon
dekobon merged commit 70194ac into main Aug 7, 2026
55 checks passed
@dekobon
dekobon deleted the fix/1152-1227-panic-free-lints branch August 7, 2026 21:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant