Skip to content

fix(metrics/loc): whitespace-only carve-out (#1087) + comment rows counted as PLOC (#1135, #1137) - #1139

Merged
dekobon merged 6 commits into
mainfrom
fix/1087-whitespace-only-loc
Jul 31, 2026
Merged

fix(metrics/loc): whitespace-only carve-out (#1087) + comment rows counted as PLOC (#1135, #1137)#1139
dekobon merged 6 commits into
mainfrom
fix/1087-whitespace-only-loc

Conversation

@dekobon

@dekobon dekobon commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Resolves #1087 with option 1 (accept and document), and fixes two
metric defects the investigation turned up.

#1087 — the whitespace-only carve-out

#1067 established that a trailing newline is a formatting detail no LOC
sub-metric may depend on. Whitespace-only source violates that: most
grammars collapse tree-sitter's root to a zero-width node at
end-of-input, so b" " reports sloc 1 and b" \n" reports
sloc 0.

The issue had measured Rust only, so the first step was measuring the
rest — accepting a limitation is only defensible if its shape is known.
It is not uniform:

Behaviour Grammars
Root collapses at EOF Rust, C, C++, mozcpp, Objective-C, C#, Java, Kotlin, Groovy, Go, JavaScript, mozjs, TypeScript, TSX, Python, Ruby, PHP, Perl, Bash, Lua — 20
Root keeps the span; both spellings agree Elixir, Tcl, iRules, preproc, ccomment5

The unterminated side is uniform: every grammar reports the row as
one blank line.

whitespace_only_input_is_the_documented_carve_out pins both sides per
language, so a grammar bump that moves a language across fails a test
rather than silently changing a metric. Option 2 (a byte-derived row
fallback) was declined: it adds a second, non-AST source of truth for a
metric the AST otherwise owns, to fix an input class real source does
not contain.

No behaviour change from this part.

#1135, #1137 — comment rows counted as code ⚠️ metric-affecting

The sweep's "whitespace is never code" assertion failed for Tcl and
iRules. Both defects let a token reach the _ catch-all ending
stats.ploc.lines.insert(start):

  • Tcl / iRules (fix(metrics/loc): Tcl/iRules count comment-only and whitespace-only rows as PLOC #1135) — the row terminator, which those two
    grammars alone surface as a token child of the root, and whose start
    row is the row it terminates. A realistic fourteen-row Tcl file
    with six comment rows reported ploc 13 against a true 7.
    Whitespace-only rows (trailing whitespace on a blank line) were also
    counted as code, so blank moves too.
  • Perl (fix(metrics/loc): Perl counts comment-only rows as PLOC #1137) — the # inside the comments node, which
    additionally reclassified the row from comment-only to
    code-and-comment. Every Perl file with a comment row was affected.
    $#array verified unaffected: the grammar emits a distinct $#
    token under length_expression.

PLOC, ploc_average, ploc_min / ploc_max, and (Tcl family)
blank move for Tcl, iRules, and Perl sources.
No other language is
affected. MI is unaffected — mi.rs reads sloc, not ploc. One
in-tree snapshot moved (tcl_cloc, metric-value-only, reviewed before
acceptance); no integration snapshot moved, as the corpus has no Tcl,
iRules, or Perl files.

Both were invisible to the existing guards: several per-language
*_cloc tests assert cloc and blank and leave ploc unpinned, so
the catch-all's own metric was the one no test named.
a_comment_row_is_never_counted_as_code now sweeps every language and
comment spelling, comment-before-code and comment-after-code — it found
the Perl case immediately after being written for the Tcl one.

Verification

  • Reverting all three arms fails exactly six tests and nothing
    else; reverting only the Perl arm fails exactly two, including the
    cross-language sweep. Perturbing the carve-out membership list fails
    only the carve-out sweep.
  • Block and doc comments swept clean in every language; Perl's line
    comment was the last instance.
  • make pre-commit green. Patch coverage above project coverage
    (metrics/loc.rs 99.94% lines vs 97.70% project).

Also in this branch

Filed, not fixed

dekobon added 6 commits July 31, 2026 13:22
Tcl and iRules are the only grammars here that surface the row
terminator as a token child of the root rather than as extra. That
token's start row is the row it terminates, so the `_` catch-all in
their `Loc::compute` credited every terminated row to PLOC —
comment-only rows and whitespace-only rows included.

A realistic fourteen-row Tcl file with six comment rows reported
`ploc 13` instead of `7`, which also drove `cloc + ploc` past `sloc`.
A wholly empty row was unaffected (the LF starting on it is the one
tree-sitter collapses at end-of-input); a row carrying trailing
whitespace was not.

`tcl_cloc` pinned the defect at `ploc == 2` for a two-row file with
one comment row; its expectation and snapshot are corrected.
`irules_cloc` asserted only cloc/blank, so it never saw the bug — it
now asserts sloc and ploc too.

Hoists the comment sitting between the `ExprCmd | Command` pattern
alternatives above the arm. A comment inside a match pattern makes
rustfmt emit the whole match verbatim while `cargo fmt --check` exits
0, so these two matches were outside the formatting gate. The site
list in .claude/rules/formatting.md never covered src/metrics/loc/;
it is corrected and its detection script now takes a directory.
The remaining loc/ bails are #1136.

Fixes #1135
#1067 established that a trailing newline is a formatting detail no
LOC sub-metric may depend on. Whitespace-only source violates that:
most grammars collapse tree-sitter's root to a zero-width node at
end-of-input, so `b"  "` reports `sloc 1` and `b"  \n"` reports
`sloc 0`. The behaviour is upstream and the newline-terminated answer
is the defensible one, so the resolution is to state the carve-out
rather than fabricate rows from a byte count — a second, non-AST
source of truth for a metric the AST otherwise owns.

Accepting a limitation is only defensible if its shape is known, and
it had only been measured for Rust. Sweeping the tree found it is not
uniform: twenty grammars collapse the root, five keep the span and are
newline-independent already. Both halves are now pinned per language
by `whitespace_only_input_is_the_documented_carve_out`, so a grammar
bump that moves a language across fails a test instead of silently
changing a metric.

No behaviour change.

Fixes #1087
`Loc for PerlCode` handles `P::Comments`, but the `#` token inside that
node fell through to the `_` catch-all. That inserted the comment row
into PLOC and, via `check_comment_ends_on_code_line`, reclassified it
from comment-only to code-and-comment. Every Perl file with a
comment-only row was affected.

`$#array` is unaffected: the grammar emits a distinct `$#` token under
`length_expression`, not `HASH`.

`perl_cloc_line_comments` pinned the defect at `ploc 3` for a
three-row fixture whose first row is comment-only; its inline snapshot
is corrected to `ploc 2`.

Adds `a_comment_row_is_never_counted_as_code`, which sweeps every
language and comment spelling in both orders. This is the generalised
form of #1135's guard, and it is what found the Perl case — the
per-language `*_cloc` tests largely leave `ploc` unpinned, and
`unterminated_one_line_file_reports_one_source_line` uses fixtures with
no comment row at all.

Fixes #1137
`.bca-baseline.toml` had drifted behind the tree: entries still
recorded values for functions that have since improved, and eighteen
carried stale `start_line`s from unrelated commits. Regenerating with
`make self-scan-write-baseline-headroom` tightens the gate without
touching any threshold.

Audited by key rather than by text diff — nothing loosens:

- added:   0 (no new offender is being masked)
- raised:  0 (no recorded value goes up)
- removed: 3 — vcs_command::emit and vcs_jit::emit (nexits),
           vcs_command::write_csv (halstead.effort); all now under the
           soft limit
- lowered: 3 — c_macro::replace and c_macro::step_normal
           (halstead.effort), vcs_command.rs loc.sloc 815 -> 787
- start_line only: 18

Split from the #1137 fix, which patched its one entry by hand so the
metric it moved stayed legible in that commit.
`Cognitive`, `Cyclomatic`, and `Abc` end their per-language matches
with `_ => {}`, so an unconsidered token costs nothing. All 24 `Loc`
implementations instead end with a catch-all that inserts the row into
PLOC — an unrecognised token silently asserts "this row is code", and
through `check_comment_ends_on_code_line` can reclassify a
comment-only row as code-and-comment.

That inverted default is the shared root cause of #1135 and #1137, and
it is non-obvious precisely because the neighbouring metric modules
train you to read `_ =>` as harmless. Recorded in `loc::shared`'s
module doc, next to the helpers those catch-alls call.

Also extends lesson #85 (coverage measures execution, not
discrimination) with the same pair as a second instance: the guards
that should have caught these are the `*_cloc` tests, several of which
assert `cloc` and `blank` and leave `ploc` unasserted — so the
catch-all's own metric was the one no test named, while
`src/metrics/loc/tcl.rs` measured 95% line coverage carrying the bug.

Evaluated as a standalone lesson and rejected: it caused real bugs but
cost no real debugging time, and its test half duplicates #85.
The list said "ten sites, all under `src/metrics/`". The real count is
36, and the largest cluster is somewhere the file never mentioned:
`src/getter/`, where 18 of 25 per-language modules bail, plus the
JS-family macro body in `src/getter.rs`. Also adds `cyclomatic/` (4)
and `abc/` (3).

Two mistakes produced the undercount, both mine in a28c886:

- The `src/metrics/`-only framing was inherited rather than checked. I
  swept `src/metrics/loc/` because that is what I was editing and
  generalised from it.
- The detection script probed only the *first* `=> {` per file. The
  bail is match-scoped, so a module whose first match formats cleanly
  reports `ok` while a later one bails (`src/getter/c.rs`), and a
  module with only expression-bodied arms has no `=> {` to probe and
  reports `SKIP` (`src/getter/go.rs`). Over `src/getter` that is 11
  false verdicts out of 18 bailing modules.

The replacement script over-indents every arm header and reports how
many rustfmt refused to restore. Extracted verbatim from the doc and
run: getter 18, cognitive 7, cyclomatic 4, abc 3, loc 2, npa 0.

Records the sharpest demonstration of the rule: over-indent
`src/getter.rs:142`, run `cargo fmt --all`, and the line stays
over-indented while `cargo fmt --all --check` exits 0.

Found by code review of #1087. Broadens the scope of #1136.
@codecov

codecov Bot commented Jul 31, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.16%. Comparing base (92707de) to head (a0cdfa1).

Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##             main    #1139   +/-   ##
=======================================
  Coverage   98.15%   98.16%           
=======================================
  Files         276      276           
  Lines       69223    69312   +89     
  Branches    68793    68882   +89     
=======================================
+ Hits        67949    68038   +89     
  Misses        873      873           
  Partials      401      401           
Flag Coverage Δ
rust 98.15% <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.92% <100.00%> (+<0.01%) ⬆️
src/metrics/loc/irules.rs 95.23% <100.00%> (ø)
src/metrics/loc/perl.rs 100.00% <100.00%> (ø)
src/metrics/loc/shared.rs 100.00% <ø> (ø)
src/metrics/loc/tcl.rs 100.00% <100.00%> (ø)
🚀 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.

@dekobon
dekobon merged commit 98600e1 into main Jul 31, 2026
39 checks passed
@dekobon
dekobon deleted the fix/1087-whitespace-only-loc branch July 31, 2026 22:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(metrics/loc): whitespace-only files break #1067's newline-independence invariant

1 participant