Skip to content

fix(npm): decide npm/npa emission by space kind alone - #1206

Merged
dekobon merged 5 commits into
mainfrom
fix/1203-npm-npa-space-kind
Aug 4, 2026
Merged

fix(npm): decide npm/npa emission by space kind alone#1206
dekobon merged 5 commits into
mainfrom
fix/1203-npm-npa-space-kind

Conversation

@dekobon

@dekobon dekobon commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Fixes #1203.

What was wrong

#1197 declared one emission rule for the object-oriented member
metrics — containers and the file unit root carry the npm / npa
block, a function space never does — but enforced it only for the ten
languages it routed through opens_member_scope. The other seven kept
enabling from their own grammar node kinds and disagreed with it in
both directions.

A kind × emission census over serde + DeepSpeech + pdf.js
(14,438 files) before this change:

space kind npm npa count
function 172,260
namespace 18,064
class 17,511
unit 13,703
struct 11,838
unit 653
impl 406
unit 81
function 44 ← the reported bug
trait 35
interface 30
unit 1

Four different unit rows is the clearest single sign the rule was a
convention rather than an invariant.

Two corrections to the issue's resolution plan

The plan's load-bearing finding was half right. is_class_space
does gate emission only — read in exactly one place, is_disabled()
wire.rs:354-356, with neither merge nor compute_sum consulting
it. But the conclusion drawn from that, that clearing the flag on a
function space keeps the parent roll-up, is false for the shapes the
issue reports:

go    func Outer(){ type inner struct{…} }  →  unit: npa ABSENT, function 'Outer': npa PRESENT
rust  fn outer(){ struct S{…} }             →  unit: npa ABSENT, function 'outer':  npa PRESENT

Go enables npm at the root only for a file with a direct
MethodDeclaration child, and Rust npa only for a module-scope
struct. In 4 of the 9 affected serde files the root carries no
block, so a plain clear would have deleted the counts from output
rather than relocating them.

The plan also recommended patching Go and Rust only. That would
have left the namespace and file-root under-emission in place.

What this does instead

Emission is a pure function of the space's kind, for every language.
is_class_space becomes space_kind: SpaceKind, recorded once per
space by the walker beside compute_wmc; opens_member_scope, both
enable_for_member_scope methods, 18 call sites and 12 per-language
enables are deleted. Net −106 lines of non-comment production code.

After — one row per kind:

space kind npm npa count
function 172,304
namespace 18,064
class 17,511
unit 13,911
struct 11,838
unit 527
impl 406
trait 35
interface 30

Npm::HAS_MEMBERS / Npa::HAS_MEMBERS is the one addition beyond the
plan, and it was necessary: a file unit is a member scope like any
other, so kind-driven emission alone gave every shell script and .c
file an all-zero block. Those 527 units are the non-OO languages held
out by it. wmc reaches the same place through a no-op compute that
records no kind.

No metric value changed — the counts always rolled up through every
enclosing space regardless of which one serialized them. Thresholds are
unaffected: bca check reads metrics.npm through MetricScope,
which never consulted the gate.

Scope decision: Go container SpaceKind — declined

Adding type … struct / type … interface to GoCode::is_func_space
and get_space_kind creates new nested spaces in every Go file,
re-partitioning every per-space metric and changing the tree shape for
every consumer. That is a major-version design change, not a bug fix.
Documented instead, in the metrics guide and the thresholds recipe: a
Go file's npm / npa live on the unit root, bca check gates them
on container spaces, therefore no npm or npa threshold can fire
on Go source
.

A pre-existing front-end divergence closes as a side effect

MetricScope::Container admits Namespace, so bca check has always
flagged namespace-scoped offenders (verified: N: npm = 4 (limit 3)).
But the Python to_sarif binding walks serialized JSON and skips a
metric whose key is absent (sarif.rs:508) — and no namespace carried
the key, so it silently dropped every one, contradicting its own claim
to match the CLI gate. SARIF output may now gain namespace-scoped
findings. Noted in the changelog.

Verification

  • make pre-commit green.
  • container_scope_tests extended from 10 languages to all 17,
    including Mozcpp — which owns no file extension and so had no
    coverage at all.
  • Every new test perturbation-verified per .claude/rules/testing.md;
    reverting is unavailable because the fix deletes the old path.
    Reinstating the defect fails 3 tests, deleting the npa seed fails
    4, flipping HAS_MEMBERS fails exactly 1.
  • The Elixir first-wins guard — the one deletion that could have moved
    a count rather than a key — confirmed inert by running a
    pre-change binary on the new fixture: Outer 3/2/1, Inner 1/0,
    Sibling 1, bit-identical.
  • 1,214 integration snapshots verified structurally before acceptance:
    added keys are exactly npm / npa and their sub-keys, removals
    exactly 39 whole npa blocks on Rust function spaces, no other
    metric key touched on either side. Submodule bumped to
    big-code-analysis-output@a23ea6a8 in the same commit as the fix.

Review passes

simplify-rust, rust-optimize, review and audit-tests each ran
over the diff; their fixes are the follow-up commits. The last of them
caught a CI break the others missed: two new tests called
assert_fixtures_present on feature-gated case lists that go empty
under --no-default-features --features rust,typescript, the
minimal-langs leg. make pre-commit runs --all-features and
structurally cannot see that class of bug.

dekobon added 5 commits August 4, 2026 09:53
#1197 declared one emission rule for the object-oriented member
metrics -- containers and the file unit carry the block, a function
space never does -- but enforced it only for the ten languages it
routed through `opens_member_scope`. The other seven kept enabling
from their own grammar node kinds and disagreed with it both ways: a
Go or Rust `struct` declared inside a function body put the block on
that function space (38 of them in the serde corpus alone, mostly
`#[test]` functions), while a C++ namespace, and any file root whose
only container sat inside a function, carried none. In that last case
the counts reached the root's `_sum` fields and nothing emitted them,
so suppressing the function-space block on its own would have deleted
them from output rather than relocating them.

Replace `is_class_space` with the space's own kind, recorded once per
space by the walker beside `compute_wmc`, and delete the eighteen
`enable_for_member_scope` call sites, the twelve direct enables and
`opens_member_scope` itself. There is no per-language surface left to
deviate on.

`Npm::HAS_MEMBERS` / `Npa::HAS_MEMBERS` keep a grammar with no
class-shaped construct anywhere (C, Bash, Lua, Perl, Tcl, iRules) out
of the output entirely, rather than giving every shell script an
all-zero block -- the file unit is a member scope like any other.
`wmc` reaches the same place via a no-op `compute` that records no
kind; the const is the equivalent for a metric whose kind comes from
the walker.

No metric value changed: the counts always rolled up through every
enclosing space regardless of which one serialized them. Thresholds
are unaffected -- `bca check` reads `metrics.npm` through
`MetricScope`, which never consulted the emission gate.

Go keeps no container `SpaceKind`. Giving it one would create new
nested spaces in every Go file and re-partition every per-space
metric, which is a major-version change to the space tree rather than
a bug fix. Its consequence is now documented instead: since `npm` /
`npa` are reported only at a Go file's root and `bca check` gates them
on container spaces, no `npm` or `npa` limit can fire on Go source.

Fixes #1203
Follow-ups from the simplify-rust, rust-optimize and review passes over
91ac421. No behaviour change.

- `set_space_kind`'s doc still described the first draft, which seeded
  the kind in `FuncSpace::new`; it is called from the walker's finalize
  step beside `compute_wmc`.
- Share one `implement_metric_trait!` arm between the `Npm` and `Npa`
  no-op impls instead of two identical bodies, mirroring the existing
  `@code_and_chain_taking` helper.
- Drop `rust_count_struct_attrs`'s `if attrs > 0` guard and the comment
  justifying it. It existed to stop a fieldless marker struct enabling
  the block; with emission decided by kind, adding zero is already a
  no-op.
- Cover Mozcpp in `container_scope_tests`. It was the one language with
  a real `Npm` / `Npa` impl left out, and the one that owns no file
  extension, so nothing else reaches it — `cpp_mozcpp_parity` compares
  metric values and would not see it lose both blocks.
- Rename `container_counts_survive_the_narrowed_enable`, which was named
  for a mechanism this change deleted.
- Replace the Go fixture's `\t` escapes with spaces.
- Record in the changelog that the Python `to_sarif` binding was
  silently dropping namespace-scoped `npm` / `npa` offenders that the
  CLI gated, because it reads the JSON and the key was absent.
`each_elixir_module_counts_only_its_own_members` contradicted itself:
the name promised a module's own members, while its first assertion
pinned `class_nm_sum() == 3` — a figure the test's own comment says
includes the nested `Inner`'s method through the roll-up.

What the value actually establishes is that no module absorbs a nested
one's methods twice; a double count reads 4. Name it that way.
`a_language_with_no_member_construct_emits_neither_block` and
`a_type_declared_inside_a_function_reaches_the_root_rollup` build a
case list whose every entry is feature-gated, then call
`assert_fixtures_present` on it. Under a feature set that enables no
listed language the list is empty and that helper fires, so the tests
failed as false positives on `--no-default-features --features
rust,typescript` — the minimal-langs CI leg. `make pre-commit` runs
`--all-features` and structurally cannot see this.

Gate each test on the union of its own fixtures' features, so it is
hidden rather than failing. Verified by removing the gate and re-running
that exact feature set.

Also correct the module doc: the space kind is recorded by the walker's
finalize step, not in `FuncSpace::new` as an earlier draft did.
The blast-radius figures came from a census over serde plus
DeepSpeech/native_client, but the sentence holding them claims the
integration corpora as a whole. Re-measured from the committed snapshot
delta itself: 39 `npa` blocks removed across 10 serde files (was 38 in
9), 1,214 file roots gaining one (was 1,252), 1,332 C++ namespaces (was
1,337).

Three claims that were false rather than imprecise:

- `STABILITY.md` still carried #1197's "outside Rust and Go" caveat, so
  the shape contract described a rule this change had already made
  unconditional.
- The book called Go "the one supported language whose space tree has no
  container kind". Bash, C, Lua, Perl, Tcl and iRules have none either;
  Go is the one that emits `npm` / `npa` without having one.
- A new docstring in `test_metrics_select.py` claimed `wmc` is elided
  from a class-less unit root. It is not — `class_interface_compute`
  records `space_kind = Unit`, and the web fixture shows `wmc` present
  there.

And a stale comment: `@code_and_chain_taking` no longer serves `Npa` /
`Npm`, which route through `@code_and_chain_taking_memberless` for
`HAS_MEMBERS`. Reaching for the old arm would silently restore the
all-zero file-root block.
@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.27%. Comparing base (39674df) to head (af1880c).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1206      +/-   ##
==========================================
+ Coverage   98.26%   98.27%   +0.01%     
==========================================
  Files         276      276              
  Lines       71583    71501      -82     
  Branches    71153    71071      -82     
==========================================
- Hits        70339    70266      -73     
+ Misses        823      820       -3     
+ Partials      421      415       -6     
Flag Coverage Δ
python 100.00% <ø> (ø)
rust 98.26% <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/macros/mod.rs 95.09% <100.00%> (+0.18%) ⬆️
src/metrics/mod.rs 100.00% <ø> (ø)
src/metrics/npa.rs 99.31% <100.00%> (-0.01%) ⬇️
src/metrics/npa/cpp.rs 96.87% <ø> (-0.35%) ⬇️
src/metrics/npa/csharp.rs 97.36% <100.00%> (-0.07%) ⬇️
src/metrics/npa/elixir.rs 100.00% <100.00%> (ø)
src/metrics/npa/go.rs 95.83% <ø> (-0.47%) ⬇️
src/metrics/npa/groovy.rs 100.00% <100.00%> (ø)
src/metrics/npa/kotlin.rs 90.69% <100.00%> (-0.22%) ⬇️
src/metrics/npa/mozcpp.rs 90.62% <ø> (-1.05%) ⬇️
... and 19 more

... and 1 file with indirect coverage changes

🚀 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 8e40738 into main Aug 4, 2026
50 checks passed
@dekobon
dekobon deleted the fix/1203-npm-npa-space-kind branch August 4, 2026 18:25
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): go and rust still enable npm/npa on function spaces

1 participant