Skip to content

fix(metrics/loc): guard doc-comment row discount at EOF#1061

Merged
dekobon merged 4 commits into
mainfrom
fix/1051-loc-doc-comment-eof-underflow
Jul 24, 2026
Merged

fix(metrics/loc): guard doc-comment row discount at EOF#1061
dekobon merged 4 commits into
mainfrom
fix/1051-loc-doc-comment-eof-underflow

Conversation

@dekobon

@dekobon dekobon commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Problem

A Rust doc comment ending at EOF without a trailing newline panicked the
analyzer, from inputs as small as /// x:

input debug release
/// x attempt to subtract with overflow Hash table capacity overflow
//! x panic panic
fn f(){}\n/// x panic panic
let x = 1; /// d panic panic
/// x\n (control) ok ok

Release builds do not save us: end wraps to usize::MAX, flows into
add_only_comment_lines, and blows up inside hashbrown while extending a
near-usize::MAX range.

Reachable from analyze / Source directly — the documented library entry
point, whose own rustdoc example uses exactly that path — and from the
Python Ast.parse(...).metrics() fast path
, which hands raw bytes to
Source::from_bytes with no normalize_eol, unlike analyze_source and
Ast.from_path (big-code-analysis-py/src/ast.rs:123,131). The bca CLI
and every bca-web endpoint that computes metrics normalize their input
and were unaffected.

Root cause

Loc discounts the extra row a DocComment spans, because the
tree-sitter-rust scanner consumes the trailing newline as part of the
token. At EOF there is no newline left to consume, so the node ends on its
own start row and the unconditional end - 1 underflows.

The two reported panic sites are one bug, and that distinction drove the
fix.
init() returns (start_row, end_row) and tree-sitter guarantees
the span is non-decreasing, so all 25 add_cloc_lines call sites are safe
— except Rust's LineComment arm, the only one that adjusts end itself:

shape rows lands at
/// x start == end == 0 underflows in loc/rust.rs directly
fn f(){}\n/// x start == end > 0 subtraction succeeds, drives end below start, underflows loc/shared.rs

A guard of end > 0 — the obvious reading of the debug panic — fixes only
the first row. The correct guard is end > start, which is also
exactly the semantics of "exclude the last line": discount the row only
when the node really spans one.

Changes

  • src/metrics/loc/rust.rs — add the end > start guard, ordered
    cheap-operand-first so it short-circuits is_child's FFI child walk on
    plain line comments. The comment records the EOF exception and cites
    process_line_doc_content by name rather than a line range, which was
    already one off against the pinned tree-sitter-rust 0.24.2.
  • src/metrics/loc/shared.rsadd_cloc_lines gains a
    debug_assert!(end >= start) and a documented precondition. It needed no
    behavioural change: the precondition is real and every caller satisfies
    it once rust.rs is fixed. Saturating arithmetic here would have traded
    wrong crashes for silently wrong comment counts, which is worse in an
    analysis tool. The comment states the assert's narrow scope: it fires
    only for end < start, only in debug, and cannot catch a caller whose own
    arithmetic already wrapped — (0, usize::MAX) satisfies end >= start.
  • src/metrics/loc.rs — four tests plus a loc_verbatim(lang, source)
    helper and its Rust binding rust_loc.
  • docs/development/lessons_learned.md — records the test-helper trap
    as a sub-example under lesson 7 (see below).

This changes metric values

Only the row-0 shapes panicked. When the EOF doc comment sat on any
later row, release builds did not crash — they silently reported one
cloc too few, which also shifted blank and MI's comment percentage.
Measured against pre-fix code under release arithmetic:

input pre-fix (release) post-fix
/// x, //! x, let x = 1; /// d panic cloc=1
fn f(){}\n/// x cloc=0 (no panic) cloc=1
fn f(){}\r\n/// x cloc=0 (no panic) cloc=1
/// a\n/// b (EOF) cloc=1 (no panic) cloc=2
any newline-terminated input identical identical

So any Rust file whose last line is an unterminated doc comment now reports
one more cloc (and one fewer blank) than a 2.0.1 release build did.
Downstreams should re-check .bca-baseline.toml entries and thresholds.

No in-tree snapshots move — every fixture is newline-terminated — confirmed
by a clean git status after the full gate.

Testing

The tests deliberately bypass check_metrics. It routes through
tools::check_func_space, which does trim_end().trim_matches('\n') then
push(b'\n') — it strips trailing newlines and appends one, making "a node
ending at EOF" structurally unreachable. The integration suites bypass that
helper but hit the same wall — read_file_with_eol also appends a trailing
newline. No test written the ordinary way could have caught this, and a
regression test added that way would have passed against the unfixed
code.
loc_verbatim goes through analyze(Source::new(..)) with no
normalization, takes a LANG (the untestable-at-EOF class is shared by
every Loc submodule), and carries a doc comment explaining why it must not
be "simplified" back.

  • rust_doc_comment_at_eof_does_not_underflow — all five shapes, including
    /// a\n/// b at EOF, which release under-counted as 1.
  • rust_doc_comment_at_eof_matches_plain_comment — parity with // x,
    plus absolute pins on the baseline so it cannot certify a wrong value on
    both sides at once.
  • rust_doc_comment_with_trailing_newline_still_discounts_the_row — the
    no-regression guard, proving the new condition did not become a no-op.
  • rust_doc_comment_crlf_still_discounts_the_row — the boundary where the
    discount is still owed (\r is ordinary content, so the \n is still
    consumed), plus the lone-trailing-\r case where it is not.

Test-via-revert per .claude/rules/testing.md, using a precise inverse
edit rather than a file-level restore (which would have wiped the
uncommitted fix): with the guard removed, the bug-pinning tests fail with
the expected panic, while the no-regression tests pass against both
versions — which is their job.

Sibling sweep

end - 1 appears in no other loc module; the doc-comment newline
adjustment is unique to Rust. Every other - 1 under src/metrics/ is
inside a test fixture's source string or unrelated cyclomatic()
arithmetic.

Lessons learned

Recorded as a sub-example under lesson 7, not as a new lesson — #7
already states the general pattern (an intermediate stage that "filters,
normalizes, or short-circuits the input") and its prescription. What this
case adds is severity: #7's existing example is a filter in one test's call
path, whereas here two independent chokepoints (check_func_space for unit
tests, read_file_with_eol for the integration suites) put the input class
out of reach of the whole suite. Lesson numbering is unchanged, so
index-based references from other skills still resolve.

Validation

make pre-commit green — 3,065 lib tests, clippy clean on default and
--all-features, markdown lint, self-scan threshold gates, no snapshot
drift, no baseline refresh.

A high-effort code review over this branch found 10 issues, all fixed in
6b30fca0 and a0ae4398; three were factual errors in this description,
corrected above. See the review comment below for the full list.

Fixes #1051

dekobon added 2 commits July 24, 2026 11:46
A Rust doc comment ending at EOF has no trailing newline for the
scanner to consume, so its `LineComment` node ends on its own start
row. The unconditional `end - 1` then underflowed: debug builds
panicked at the subtraction, release builds wrapped to `usize::MAX`
and surfaced far away as a hash-table capacity overflow while
inserting comment rows. Inputs as small as `/// x` were affected.

Both reported panic sites are one bug. When `start == end == 0` the
subtraction underflows directly; when `start == end > 0` it succeeds
but drives `end` below `start`, underflowing `add_cloc_lines`
instead. A guard of `end > 0` would fix only the first case, so the
guard is `end > start` — discount the row only when the node really
spans one.

Rust's `LineComment` arm is the only caller that adjusts `end` before
calling `add_cloc_lines`; the other 24 pass `init`'s span unmodified.
`add_cloc_lines` therefore gains a `debug_assert` documenting that
precondition, so a future caller reintroducing the inversion fails
loudly in tests instead of wrapping in release.

The first-party binaries never reached this — `bca`, `bca-web`, and
the Python bindings all normalize line endings — but library callers
using `analyze` / `Source` directly did, which is the documented
entry point and the shape a fuzz harness would use.

Values for newline-terminated input are unchanged, so no snapshots
move. Tests go through `Source` directly: `check_metrics` trims and
re-appends a trailing newline, which makes these inputs unreachable
through it and any such test vacuous.

Fixes #1051
Records the #1051 test-helper trap as a new sub-example under lesson 7
rather than as a new lesson. Lesson 7 already states the general
pattern — an intermediate stage that "filters, normalizes, or
short-circuits the input" makes a wrapper-level test pass for the
wrong reason — so a standalone entry would have restated it with a
different anecdote.

What the new case adds is severity, not principle. Lesson 7's existing
NaN example is a filter in one test's call path; here the
normalization lives in `check_func_space`, the helper every LOC test
shares, so it does not make one test vacuous — it makes an entire
input class (a node ending at EOF) unreachable for the whole suite,
and would have made a regression test written the ordinary way pass
against the unfixed code.

No renumbering: appended within lesson 7, so the lesson numbers other
skills reference by index are unchanged.

Follow-up to aaddda9 (#1051).
@dekobon dekobon added bug Something isn't working security Security-relevant finding rust Pull requests that update rust code labels Jul 24, 2026
@codecov

codecov Bot commented Jul 24, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.03%. Comparing base (79d748e) to head (a0ae439).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1061   +/-   ##
=======================================
  Coverage   98.03%   98.03%           
=======================================
  Files         267      267           
  Lines       65968    66021   +53     
  Branches    65538    65591   +53     
=======================================
+ Hits        64673    64726   +53     
  Misses        854      854           
  Partials      441      441           
Flag Coverage Δ
rust 98.02% <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/metrics/loc.rs 99.97% <100.00%> (+<0.01%) ⬆️
src/metrics/loc/rust.rs 100.00% <100.00%> (ø)
src/metrics/loc/shared.rs 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

dekobon added 2 commits July 24, 2026 14:14
Code-review follow-ups on the #1051 guard.

Order the guard's operands cheap-first: `end > start` is false for
every plain line comment, since only the doc-comment scanner consumes
the trailing newline, so it short-circuits `is_child`'s FFI child walk
away on the common case. Both operands are pure, so this is
semantically identical.

Correct the `add_cloc_lines` comment, which claimed the `debug_assert`
prevents "wrapping to usize::MAX in release". It cannot: for `/// x`
the caller's arithmetic wraps before the call, so the helper sees
(0, usize::MAX), `end >= start` holds, and the assert passes. Release
also disables debug-assertions and overflow-checks outright. The
comment now states the narrow scope the assert actually has.

Cite `process_line_doc_content` by name instead of a scanner.c line
range; the range was already one off against the pinned 0.24.2.

Tests: `loc_verbatim` now takes a `LANG` rather than hard-coding Rust
behind a language-agnostic name — the untestable-at-EOF class is
shared by every Loc submodule — with `rust_loc` as the explicit Rust
binding. Add CRLF coverage, the one boundary where the discount must
still apply and nothing pinned it. Pin `/// a\n/// b` at EOF, which
release under-counted as 1. Add absolute assertions to the parity
test so it cannot certify a wrong value on both sides at once.
The #1051 changelog entry was wrong on three counts, all found in
review.

It claimed the Python bindings normalize line endings. `PyAst::parse`
does not — it hands raw bytes to `Source::from_bytes`, unlike
`analyze_source` and `Ast.from_path` — so `Ast.parse(..).metrics()`
was itself exposed to the crash, not shielded from it.

It claimed "metric values for all other inputs are unchanged". Only
the row-0 shapes panicked. When the EOF doc comment sat on a later
row, release builds did not crash: they silently reported one cloc too
few, which also shifted blank and MI's comment percentage. Those
values change with the fix, so the entry now says so and tells readers
to re-check baselines.

It claimed bca-web normalizes, blanket. Three of its seven
source-consuming handlers deliberately do not (#640); the conclusion
survives only because none of them compute Loc.

Also narrow the lessons-learned claim that "every LOC test routes
through check_metrics" — the integration suites route through
read_file_with_eol instead. They hit the same wall, since that path
also appends a trailing newline, which makes the lesson stronger: two
independent chokepoints put the input class out of reach.
@dekobon

dekobon commented Jul 24, 2026

Copy link
Copy Markdown
Owner Author

Ran a high-effort code review (8 finder angles) over the branch. It found
10 issues, all now fixed in 6b30fca0 and a0ae4398. Three were factual
errors in this PR's own description, so the body above has been
corrected — flagging that here since it changes the stated impact.

The two that matter

1. The Python bindings were exposed; I said they weren't.
PyAst::parse (big-code-analysis-py/src/ast.rs:123,131) calls
extract_source_bytes then Source::from_bytes with no normalize_eol
— unlike analyze_source (analysis.rs:422) and Ast.from_path. So
Ast.parse("/// x", "rust").metrics() hit the same crash on a release
wheel. The original claim that all three first-party surfaces normalized
was wrong.

2. "No metric drift" was wrong. I verified the panic on three inputs
and generalized. Testing a fourth showed release builds do not panic
when the doc comment sits past row 0 — they silently under-count cloc:

input pre-fix (release) post-fix
/// x, //! x, let x = 1; /// d panic cloc=1
fn f(){}\n/// x cloc=0 (no panic) cloc=1
/// a\n/// b (EOF) cloc=1 (no panic) cloc=2
newline-terminated identical identical

Three input classes change value. The changelog now says so and tells
readers to re-check baselines. No in-tree snapshots move (every fixture is
newline-terminated), which is why the gate stayed green and hid this.

Also fixed

  • The debug_assert didn't guard what its comment claimed. For /// x
    the caller's arithmetic wraps before the call, so add_cloc_lines sees
    (0, usize::MAX), end >= start holds, and it passes — then
    add_only_comment_lines(0, usize::MAX) overflows anyway. Release disables
    debug-assertions and overflow-checks, so it cannot run there at all.
    Comment now states its actual narrow scope rather than overclaiming.
  • bca-web normalization claim was blanket but isn't — three of seven
    source-consuming handlers deliberately skip it (fix(lib): normalize EOL/trailing newline on in-memory sources so web and Python match the CLI #640); the conclusion only
    survives because none compute Loc.
  • CRLF was untested — the one boundary where the discount must still
    apply. Now pinned, including a lone trailing \r.
  • loc_verbatim hardcoded LANG::Rust behind a language-agnostic name;
    now takes a LANG, with rust_loc as the explicit binding.
  • Guard operands were ordered expensive-first; end > start is false for
    every plain line comment, so it now short-circuits is_child's FFI walk.
  • Stale scanner.c#L194-L195 citation (one off against the pinned
    0.24.2) replaced with the function name, which can't rot.
  • Lessons-learned over-claimed "every LOC test routes through
    check_metrics" — the integration suites use read_file_with_eol. They
    hit the same wall, which makes the lesson stronger: two independent
    chokepoints, not one.
  • assert_eq!(ploc(), 0) asserted the default value; added absolute
    baseline pins so the parity test can't certify a wrong value on both sides.

Not fixed — needs its own issue

Sloc::sloc() computes end - start with no +1 for unit spans, so
analyze(Source::new(LANG::Rust, b"fn main() {}")) reports sloc == 0 for
a one-line file, and mi::inputs_are_empty() then returns 0.0 for real
input. Pre-existing, affects every language, and correcting it would move
metric values broadly — not something to slip into a doc-comment fix.

make pre-commit green: 3,065 lib tests, clippy clean on both feature
flavours, no snapshot drift, no baseline refresh.

@dekobon
dekobon merged commit 92680fa into main Jul 24, 2026
39 checks passed
@dekobon
dekobon deleted the fix/1051-loc-doc-comment-eof-underflow branch July 24, 2026 23:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working rust Pull requests that update rust code security Security-relevant finding

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(metrics/loc): usize underflow panics on Rust doc comment at EOF

1 participant