Skip to content

fix(npm): emit npm/npa on member scopes, not every space - #1205

Merged
dekobon merged 6 commits into
mainfrom
fix/1197-npm-container-scope
Aug 4, 2026
Merged

fix(npm): emit npm/npa on member scopes, not every space#1205
dekobon merged 6 commits into
mainfrom
fix/1197-npm-container-scope

Conversation

@dekobon

@dekobon dekobon commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Npm and Npa decided whether to emit their serialized block by asking
Checker::is_func_space(node) — "does this node open a space" — rather
than "is this a scope that owns methods and attributes". The two
questions had drifted apart, and #1184 made the gap visible.

Fixes #1197.

The defect is wider than the issue reports

Measured with bca metrics before the fix:

Fixture unit class plain method #1184 construct
C.kt / C.java / C.groovy block block block
C.js / C.ts / C.cs / C.rb / C.php block block block block

C#, JavaScript, MozJS, TypeScript, TSX, PHP and Ruby list ordinary
methods in is_func_space, so every method already carried an all-zero
block. In the JS family class_static_block was therefore not a new
inconsistency — just one more instance of a pre-existing one. The
inconsistency the issue shows is real for Kotlin, Java and Groovy, whose
methods are is_func only.

Ten Npm / Npa impls used the is_func_space shape and all ten moved.
Python, C++, Mozcpp, Objective-C and Elixir gate on their own node kinds
and are correct; Rust and Go also gate on their own kinds but are not
correct — see the follow-up below.

MetricScope::Container is the wrong predicate

The issue proposes gating on "is this a container space", and
MetricScope::Container looks ready-made. It is not: it excludes
SpaceKind::Unit, so gating on it also deletes the whole-file
roll-up
— 400 non-zero values across the integration corpus, including
php/traits_enums.php's root class_npm_sum: 7.

It would also have left the three OO metrics disagreeing about the same
root, because wmc carries the same MetricScope::Container but gates
on !matches!(kind, Function | Unknown) — dropping function spaces and
keeping the roll-up. That is the rule #1197 actually wants, since the
reported symptom is a function space carrying a block its sibling method
does not.

The first two commits here implement the container-only version; review
caught it and 042a258c corrects it. The rule now lives once, as
SpaceKind::is_member_scope, read by wmc, npm and npa alike.

Result

6,974 blocks removed across 213 integration snapshots. 6,969 were
entirely zero. The five that were not belong to a function lexically
containing a class — a PHP new class { … }, a JavaScript class inside
a callback — where the block was that nested class's roll-up; it remains
on the class's own space and in the file-root total, the position wmc
has always omitted.

No metric value changed: is_class_space feeds only
wire::CodeMetrics::from, never merge.

Notes for the reviewer

  • metrics::opens_member_scope classifies through the source-aware
    is_func_space_with_code / get_space_kind_with_code, the pair the
    walker itself uses. The byte-less forms disagree for Elixir, whose
    defmodule is a Class only to the source-aware getter — routing
    Elixir through the helper with the plain forms would silently emit
    nothing for every module, with no test failure.
  • The tests serialize a whole FuncSpace, because both existing
    surfaces are blind to this gate by construction: check_metrics hands
    back the ungated Stats, and the insta snapshots go through
    serialize_via_wire!, which bypasses the Option. That blindness is
    why the defect shipped — perturbing the predicate back fails only the
    new tests out of 3,313.
  • Both directions of the rule are pinned: narrowing to containers fails
    the_file_root_keeps_its_rollup, widening back to is_func_space
    fails function_spaces_emit_neither.
  • The submodule bump is dekobon/big-code-analysis-output@f7579131,
    already pushed.
  • .bca-baseline.toml moves one entry: npa/php.rs's halstead.effort
    on the two threaded arguments. Two other entries dropped off entirely
    when the duplicated enable block was folded into a method.

Follow-ups filed, not folded in

dekobon added 6 commits August 3, 2026 20:00
`Npm` and `Npa` enabled themselves from `Checker::is_func_space`, which
answers "does this node open a space", not "is this a container that owns
methods and attributes". Ten languages emitted an all-zero block on the
whole-file unit root, and C#, JavaScript, MozJS, TypeScript, TSX, PHP and
Ruby emitted one on every ordinary method too. #1184 then gave Kotlin
`get()`/`set()`/`init {}`, Java and Groovy `static {}` and the JS-family
`class_static_block` their own function spaces, so those grew a block the
plain method beside them did not have -- the symptom this issue reports.

The enable now goes through `metrics::opens_container_space`, which
reuses `MetricScope::Container` rather than re-deriving it. That is the
same set of space kinds `bca check` and the SARIF export already gate
these metrics on (#969), so emission and thresholding are one rule.

Only the ten `is_func_space`-shaped sites move. Python, Rust, C, C++,
Mozcpp, Go, Objective-C and Elixir already gate on node kind; their
gates are deliberate and documented in place.

No metric value changes: `is_class_space` gates serialization only, and
`merge` sums the roll-up regardless. 3,765 all-zero blocks disappear from
278 integration snapshots, none of them C/C++/Rust.

The new tests serialize a whole `FuncSpace` rather than reading
`space.metrics`, because both existing surfaces are blind to this gate --
`check_metrics` hands back the ungated struct and the `insta` snapshots
go through `serialize_via_wire!`. Perturbing the helper back to
`is_func_space` fails exactly the two new negative tests and nothing else
in the 3,312-test lib suite, which is why the defect shipped.

`src/metrics/npa/php.rs`'s baselined `halstead.effort` moves +0.7% on the
call-site rename; the baseline is refreshed in the same commit.

Fixes #1197
The three-line enable block was repeated verbatim at 16 sites across
`npm` and `npa`. `Stats::enable_for_container` names the idempotence
invariant once instead of restating `&& stats.is_disabled()` at every
call, and checks the cheap flag before the two predicate calls rather
than after.

Fewer tokens in those bodies also moves three baselined metrics the
right way: `npa/php.rs`'s `halstead.effort` drops 52615 -> 49514, back
under the 50000 limit, and `npa/csharp.rs` and `npa/kotlin.rs` fall off
the baseline entirely as their `cognitive` clears 15.

Test fixes in the same pass: the space name is modelled as `Option`
rather than defaulted, since the verbatim root legitimately has none and
a defaulted `""` would match nothing silently; and the #1184 fixtures
are looked up by language instead of by index into `FIXTURES`.
`opens_container_space` drops from `pub(crate)` to private: every caller
is a descendant of `crate::metrics`. (`average` beside it keeps
`pub(crate)` — `wire::metrics` and `output::funcspace_row` do reach it
from outside.)

In the container-scope tests, `only_space` and `summary` replace three
copies of the "every space as (kind, name)" diagnostic and two copies of
the exactly-one-match assertion. The `kind != Unit` filter goes with
them: it guarded a collision that became impossible once the space name
was modelled as `Option`, since the verbatim root has none.

cargo clippy -W clippy::pedantic reports zero warnings for the crate, so
there was no lint triage to apply.
Two independent reviewers caught that the first cut of #1197 narrowed
too far. `MetricScope::Container` excludes `SpaceKind::Unit`, so gating
on it deleted the whole-file `npm`/`npa` roll-up as well as the all-zero
function-space noise -- 400 non-zero values across the integration
corpus, e.g. `php/traits_enums.php`'s root `class_npm_sum: 7`. The
CHANGELOG claimed nothing but zeros had been removed; that was wrong,
and it was the sentence a reader would use to size the upgrade.

It also left the three OO metrics disagreeing about the same root: `wmc`
is `MetricScope::Container` too, but its rule is `!matches!(kind,
Function | Unknown)` -- it drops function spaces and keeps the roll-up.
That rule is the one #1197 actually asks for, since the reported symptom
is a `<get>` space carrying a block its sibling method does not.

`SpaceKind::is_member_scope` now states it once and `wmc`, `npm` and
`npa` all read it, so they cannot drift apart again.

Also from review:

- `opens_member_scope` classifies through `is_func_space_with_code` /
  `get_space_kind_with_code`, the pair the walker itself uses. The
  byte-less forms disagree for Elixir, whose `defmodule` is a `Class`
  only to the source-aware getter, so routing Elixir through the helper
  would have silently emitted nothing for every module.
- The test file is feature-gated per language and calls
  `assert_fixtures_present`. It previously panicked under the
  `minimal-langs` CI feature set instead of compiling out.
- New coverage: the file-root roll-up in both directions (a
  container-only rule fails it), C#'s expression-bodied property and
  accessor-less indexer, and the interface-side counters.
- Corrected doc claims that were false for Go and Rust, which set the
  flag from their own node kinds and still enable a file root the shared
  rule would not; the book's claim that CSV writes zeros (it writes the
  real values); and a doc that undercounted the affected languages.
- STABILITY.md now states that which space carries which block is not
  part of the shape contract.
- The Python binding test reads the root again and gained a companion
  asserting the absence on function spaces.

`npa/php.rs`'s baselined `halstead.effort` moves 49514 -> 51275 on the
two threaded arguments; baseline refreshed in the same commit.
`test_class_only_metrics_skip_function_spaces` used the `Hello.java`
fixture, but Java's `is_func_space` never listed `method_declaration` --
a Java method carried no npm/npa block even before #1197, so asserting
its absence held either way. The test passed for the wrong reason.

JavaScript's `is_func_space` did list every function form, so the three
function spaces in the new inline fixture -- a class method, a static
block, and a top-level function -- each really carried an all-zero block
in 2.0.x. Verified by rebuilding the extension with the pre-fix
predicate: the test fails on `<static-init>`, and passes once restored.

Also asserts the class space still carries both blocks, and pins the
space counts, so a fixture that stopped parsing cannot make the loop
vacuous.
Review found the Go/Rust caveat stated backwards. Probed: for both
languages a `struct` declared inside a function puts the block on that
*function* space and leaves the unit root without one — the opposite of
what the shared rule predicts, and the opposite of what the docs said.
Corrected in the book, the two `Stats` doc comments and STABILITY.md,
which each carried an unqualified "never on a function space".

`{#oop-emission-scope}` sat on a paragraph, where mdBook renders it as
literal text and leaves the `[above](#oop-emission-scope)` link with no
target. It is now a real heading, matching the book's convention that
every explicit anchor sits on one.

Also repoints a doc link at the renamed `function_spaces_emit_neither`
— `#[cfg(test)]` keeps rustdoc's `-D warnings` from catching it — and
merges two split `use crate::node::…` lines.
@codecov

codecov Bot commented Aug 4, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.26%. Comparing base (f55c5e8) to head (2203b19).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1205      +/-   ##
==========================================
- Coverage   98.26%   98.26%   -0.01%     
==========================================
  Files         276      276              
  Lines       71584    71583       -1     
  Branches    71154    71153       -1     
==========================================
- Hits        70340    70339       -1     
  Misses        823      823              
  Partials      421      421              
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.25% <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/mod.rs 100.00% <100.00%> (ø)
src/metrics/npa.rs 99.31% <100.00%> (+<0.01%) ⬆️
src/metrics/npa/csharp.rs 97.43% <100.00%> (-0.13%) ⬇️
src/metrics/npa/groovy.rs 100.00% <100.00%> (ø)
src/metrics/npa/kotlin.rs 90.90% <100.00%> (-0.40%) ⬇️
src/metrics/npa/php.rs 94.59% <100.00%> (-0.28%) ⬇️
src/metrics/npa/ruby.rs 94.11% <100.00%> (-0.62%) ⬇️
src/metrics/npm.rs 98.14% <100.00%> (+<0.01%) ⬆️
src/metrics/npm/csharp.rs 97.56% <100.00%> (-0.12%) ⬇️
src/metrics/npm/groovy.rs 100.00% <100.00%> (ø)
... and 5 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.

@dekobon
dekobon merged commit 39674df into main Aug 4, 2026
50 checks passed
@dekobon
dekobon deleted the fix/1197-npm-container-scope branch August 4, 2026 13:41
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(npm): the #1184 function spaces emit npm/npa blocks their sibling methods do not

1 participant