Skip to content

fix(npm): go and rust still enable npm/npa on function spaces #1203

Closed

Description

@dekobon

Fix open in #1206, not yet merged — this issue closes when that
PR lands (its Fixes #1203 trailer does it automatically). Submodule
snapshots are already pushed as
big-code-analysis-output@a23ea6a8. Emission becomes a pure function
of SpaceKind for every language, not just Go and Rust. Two corrections to the analysis below, both verified by
measurement — see
the closing comment:

  1. The resolution plan's claim that clearing the flag "suppresses that
    space's block without losing the parent roll-up" is false for
    the shapes this issue reports. Go's root enables only on a direct
    MethodDeclaration child and Rust npa only on a module-scope
    struct, so in 4 of the 9 affected serde files the root carries
    no block and a plain clear would have deleted the counts from
    output rather than relocating them.
  2. The deviation was wider than Go and Rust: all 18,064 C++
    namespace spaces and 13,703 file roots in the corpora were member
    scopes emitting nothing.

The Go container-SpaceKind question in Scope was decided
no — it is a space-tree shape change for the next major. Its
consequence is documented instead: no npm / npa threshold can fire
on Go source.

Summary

#1197 gave npm / npa a single emission rule —
SpaceKind::is_member_scope: containers and the file root carry the
block, a function space never does — and routed ten languages through
metrics::opens_member_scope to enforce it.

Go and Rust were deliberately left on their own node-kind gates, on the
grounds that those gates target real containers. For Go that is not
true, and for Rust it is only half true.

Go

GoCode::get_space_kind has no container kind at all — Go spaces are
only Function and Unit. The enable therefore targets the file root
and, in one path, whatever space encloses a type literal:

  • src/metrics/npm/go.rs:25 enables on G::SourceFile, which is
    SpaceKind::Unit.
  • src/metrics/npa/go.rs:116 enables on G::StructType, which opens no
    space, so the flag lands on the enclosing space. For a struct declared
    inside a function body that is a Function space — precisely the
    shape fix(npm): the #1184 function spaces emit npm/npa blocks their sibling methods do not #1197 removed everywhere else.
  • src/metrics/npm/go.rs:90 does the same for G::InterfaceType; its
    own comment says "the count lands on the enclosing space (typically
    Unit)".

A consequence worth stating separately: bca check --threshold npm=… is
MetricScope::Container-scoped, and Go has no container space, so a Go
file's npm can never be gated by a threshold no matter what it
measures.

Rust

src/metrics/npa/rust.rs:128 (rust_count_struct_attrs) sets the flag
on the enclosing func_space, and RustCode::is_func_space includes
SourceFile, FunctionItem and ClosureExpression. So
struct S { pub a: u8 } at module scope enables npa on the Unit root
— fine — but a struct declared inside a function enables it on a
Function space.

npm is not affected: src/metrics/npm/rust.rs:37 gates on
ImplItem | TraitItem, both real containers.

Why this was not folded into #1197

Both are pre-existing and neither is the reported symptom. Fixing them
is not a matter of routing them through opens_member_scope: Go's
counts are deliberately attributed to the enclosing space because Go
has no container to attach them to, so the fix needs a decision about
what a Go npm means before it needs code. Rust's npa has the same
question for a bare struct at module scope, which today is the file
root's roll-up and arguably correct.

Scope

  • Decide whether Go should gain a container SpaceKind for
    type … struct / type … interface, or whether its OO metrics should
    stay Unit-scoped and be documented as such.
  • Either way, stop both languages enabling on a Function space.
  • The doc claims in src/metrics/npm.rs, src/metrics/npa.rs and
    big-code-analysis-book/src/metrics.md currently carry an explicit
    Go/Rust caveat added by fix(npm): the #1184 function spaces emit npm/npa blocks their sibling methods do not #1197; remove it when this lands.
  • Extend src/metrics/container_scope_tests.rs to cover Go and Rust
    once they obey the shared rule.

Resolution Plan

Confirmed against main (39674df6, which corrected the docs to admit
the deviation but did not change the behaviour). Every cited site is
live: src/metrics/npm/go.rs:25 (G::SourceFile), :89 (the
InterfaceType arm's if stats.is_disabled() { stats.is_class_space = true; }), src/metrics/npa/go.rs (the StructType arm's identical
enable), and src/metrics/npa/rust.rs:128.

The load-bearing finding: the flag only controls emission

is_class_space is read in exactly one place —
src/wire.rs:354-356, which gates whether the npm / npa block is
serialized. Neither Stats::merge (src/metrics/npm.rs:79) nor
compute_sum (:216) consults it. Clearing the flag on a function
space therefore suppresses that space's block without losing anything
from the parent roll-up
— the sums have already been accumulated into
class_npm_sum and friends by the time the space is finalized.

That makes the fix much smaller than the issue anticipates, and it
decouples the two halves: stopping the function-space emission needs no
decision about what a Go npm means. Only the second half does.

  1. Enforce the rule once, at the walker, not per language. Clear
    npm.is_class_space / npa.is_class_space in finalize_state
    (src/spaces/compute.rs:150) whenever
    !state.space.kind.is_member_scope(). Both the space kind and the
    populated metrics are in hand there, and finalize's pop arm already
    runs a kind-aware step (compute_wmc) at the same point, so this is
    the established seam rather than a new one. Doing it here rather than
    editing Go's and Rust's enables means the rule holds for every
    language, including the next one added, and a language can no longer
    opt out of it by gating on its own node kinds. Keep
    opens_member_scope as the enable — it stays the right predicate for
    the ten languages that route through it; the walker step becomes the
    backstop that makes the invariant unconditional.
  2. Decide the Go container question — and the recommendation is to say
    no.
    Giving Go a container SpaceKind for type … struct /
    type … interface means adding those kinds to GoCode::is_func_space
    and get_space_kind, which creates new nested spaces in every Go
    file's output. That is a far larger change than moving a metric block:
    every per-space metric (loc, cyclomatic, nom, …) re-partitions,
    the space tree changes shape for every Go consumer, and the whole Go
    integration corpus churns. Weighed against what it buys — the ability
    to gate npm on a Go file — it is not worth doing as a bug fix. Keep
    Go's OO metrics Unit-scoped, document that explicitly (see step 4),
    and if container spaces for Go are wanted, raise them as a separate
    design change against the next major, where a space-tree shape change
    belongs.
  3. Rust npa. rust_count_struct_attrs
    (src/metrics/npa/rust.rs:89, enable at :128) attributes a struct's
    fields to the enclosing is_func_space, which includes
    SourceFile, FunctionItem and ClosureExpression. Step 1 already
    suppresses the block on the two function kinds while leaving the
    module-scope SourceFile roll-up intact, which the issue itself calls
    "arguably correct". No Rust-specific code change is needed —
    confirm that with a fixture rather than assuming it. npm for Rust
    gates on ImplItem | TraitItem (src/metrics/npm/rust.rs:37), both
    real containers, and is unaffected.
  4. Documentation. Remove the Go/Rust caveat that 39674df6 added to
    src/metrics/npm.rs, src/metrics/npa.rs and
    big-code-analysis-book/src/metrics.md — once step 1 lands, the
    shared rule is true without exception. Replace it with the positive
    statement that Go's npm / npa are file-scoped because Go has no
    container space, and its corollary: bca check --threshold npm=… is
    MetricScope::Container-scoped, so a Go file's npm can never trip
    a threshold
    . That consequence is the one a user is most likely to
    hit and least likely to derive; it belongs in the book's metrics page
    and in the thresholds recipe, not only in a source comment.
  5. Tests. Extend src/metrics/container_scope_tests.rs to Go and
    Rust: a Go struct and interface declared inside a function body
    must leave the enclosing function space with no npm / npa block
    while the file root still carries the roll-up; a Rust struct inside
    a function likewise. Assert both directions — the presence of the root
    block and the absence of the function-space one — because a rule that
    deleted the roll-up would pass an absence-only test, which is the
    regression c028976e had to correct on fix(npm): the #1184 function spaces emit npm/npa blocks their sibling methods do not #1197.
  6. Verify by revert per .claude/rules/testing.md: with the
    finalize_state clearing removed, the new Go and Rust tests must fail
    and the existing container tests must still pass. If the existing ten
    languages' tests also fail, the walker step is doing work
    opens_member_scope was already doing and the two need reconciling
    rather than layering.
  7. Snapshots and changelog. Go and Rust files in
    tests/repositories/big-code-analysis-output lose npm / npa
    blocks on function spaces; all four AGENTS.md submodule steps apply.
    CHANGELOG.md ## [Unreleased] → Fixed, framed as the completion
    of fix(npm): the #1184 function spaces emit npm/npa blocks their sibling methods do not #1197: the emission rule now holds for every language, with the
    STABILITY.md note that which space carries which block is not part
    of the shape contract already covering it.

Assessment

Dimension Rating
Difficulty Low
Complexity Medium
Priority Medium

Difficulty — Low. Downgraded from what the issue implies. Because
is_class_space gates emission only and the roll-up is independent of
it, the behavioural fix is a few lines in finalize_state rather than a
rework of Go's and Rust's enables. The bulk of the work is tests, docs
and snapshots.

Complexity — Medium. One crate, one new line in the walker, but it
changes serialized output for two languages, churns integration
snapshots with a submodule bump, and carries a design decision (step 2)
that should be recorded even though the recommendation is to decline it.

Priority — Medium. No count is wrong — the numbers on containers and
on the file root are correct, and the roll-up is intact. What is wrong is
which space carries the block, which #1197 already declared the rule for,
so this is an unfinished fix rather than a new defect. The Go threshold
gap is a real user-visible limitation but a narrow one, and documenting
it (step 4) addresses most of the harm.

Activity

  1. dekobon commented on Aug 4, 2026

    @dekobon
    OwnerAuthor

    Added resolution plan and assessment ratings (difficulty Low / complexity Medium / priority Medium).

    Confirmed every cited site is still live on main (39674df6 corrected the docs to admit the deviation but did not change behaviour).

    The plan turns on one finding that shrinks the fix considerably: is_class_space is read in exactly one place — src/wire.rs:354-356, which gates serialization. Neither Stats::merge nor compute_sum consults it. So clearing the flag on a function space suppresses that space's block without losing the parent roll-up, because the sums were already accumulated. That means the two halves of this issue decouple: stopping the function-space emission needs no decision about what a Go npm means, and can be done once in finalize_state (src/spaces/compute.rs:150, where kind and the populated metrics are both in hand and compute_wmc already runs) rather than by editing Go's and Rust's enables. Doing it at the walker also makes the rule hold for languages added later, which a per-language edit cannot.

    On the scope question the recommendation is to decline giving Go container SpaceKinds: it would create new nested spaces in every Go file, re-partitioning every per-space metric and changing the space tree shape for all consumers — a major-version design change, not a bug fix. Keep Go Unit-scoped and document the consequence that a Go file's npm can never trip a MetricScope::Container threshold.

    No low-priority label: difficulty and complexity are not both Low, and priority is Medium.

  2. dekobon commented on Aug 4, 2026

    @dekobon
    OwnerAuthor

    Fixed in 91ac4213 (+ e165abf4, c6c4885e). Submodule snapshots in
    dekobon/big-code-analysis-output@a23ea6a8, recorded in the same parent
    commit. Not yet pushed — awaiting authorization.

    Two things in this issue's analysis did not survive verification

    1. The resolution plan's load-bearing finding was half right. It is
    true that is_class_space gates emission only — it is read in exactly
    one place, is_disabled() → src/wire.rs:354-356, and neither
    Stats::merge nor compute_sum consults it. But the conclusion drawn
    from that — that clearing the flag on a function space "suppresses that
    space's block without losing the parent roll-up" — is false for
    exactly the shapes this issue is about:

    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 has no npa
    block at all, so a plain clear would have deleted the counts from output
    rather than relocating them — they reach the root's _sum fields, and
    nothing serializes those.

    2. The deviation is wider than Go and Rust. A kind × emission census
    over serde + DeepSpeech + pdf.js (14,438 files) before the fix:

    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

    Every C++ namespace and 13,703 file roots were member scopes emitting
    nothing. Four different unit rows is the clearest single sign that the
    rule was a convention rather than an invariant.

    What was done instead

    Emission is now a pure function of the space's kind, for every language —
    the option the issue frames as "either way, stop both languages enabling
    on a Function space", taken to its end rather than applied to two
    languages. is_class_space is replaced by 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:

    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

    One row per kind. The 527 units emitting nothing are the non-OO
    languages — shell, C, Perl — held out by a new Npm::HAS_MEMBERS /
    Npa::HAS_MEMBERS, because a file unit is a member scope like any other
    and would otherwise give every shell script an all-zero block. wmc
    reaches the same place through a no-op compute that records no kind.

    No metric value changed. Thresholds are unaffected: bca check reads
    metrics.npm through MetricScope, which never consulted the gate.

    Scope decisions

    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 closed 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 of them, contradicting its own
    claim to match the CLI gate. SARIF output may now gain findings.

    Verification

    • make pre-commit green. Library suite 3,317 tests.
    • 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 was not available, since 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, was 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.
  3. reopened this on Aug 4, 2026
  4. dekobon commented on Aug 4, 2026

    @dekobon
    OwnerAuthor

    Reopened — I closed this on commit rather than on merge, which was premature. The fix is in #1206 and this issue will close automatically when that lands, via the PR body's Fixes #1203 trailer.

    Nothing about the analysis in the comment above changes; only the status did.

  5. added 3 commits that reference this issue on Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions