ludo-technologies / ludo-technologies/polyscan
[BUG][auto] generic/zero-coupling-types-excluded-from-score-denominator: one coupled type drops Go/Rust/C++ coupling to 0/100
- Dominant language
- Go
- Stars
- 12
- Forks
- 7
- Avg merge
- 5h 46m
- Merged PRs (30d)
- 49
Description
The generic (Go/Rust/C++) coupling collector drops every zero-coupling type before the summary is built, so the coupling penalty is computed over *only the coupled types*. The denominator therefore counts problem types against problem types, and a package with one coupled type scores 0/100 on coupling no matter how much well-decoupled code surrounds it.
Unlike the JS/TS path, this exclusion is **hardcoded** — there is no flag or config that restores the full population.
## Repro
`main.go` — 12 fully decoupled types, then the same file with one coupled type added:
```go
package main
type Dep0 struct{}
func (d Dep0) Run() int { return 0 }
// ... Dep1 .. Dep11, identical
func main() {}
```
```bash
polyscan analyze --format json .
```
| target | `cbo_classes` | `coupling_score` | `health_score` |
|---|---|---|---|
| 12 decoupled types | 0 | **100** | 97 (A) |
| same 12 + **one** coupled type (`Hub` with 12 fields) | 1 | **0** | 76 (B) |
Adding a single coupled type to an otherwise perfectly decoupled package takes coupling from 100 to 0 and the grade from A to B. The 12 clean types are invisible to the metric, so no amount of good design can move the score back.
With the true population the result is unremarkable: weighted problematic = 1, ratio = 1/13 = 0.077, `penalty = 0.077 / 0.40 * 20 ≈ 4`, i.e. **coupling 80/100**, not 0.
A larger fixture behaves the same way: 13 types where only `Hub` is coupled reports `total_classes: 1`, `high_risk_classes: 1`, `cbo_distribution: {"10+": 1}` — the 12 zeros are absent from every count.
## Cause
`polyscan/internal/analysis/coupling.go:317-320` skips zero-coupling results as they are collected:
```go
coupling := &Coupling{Classes: []CoupledClass{}, FilesAnalyzed: len(b.files), Warnings: b.warnings}
for i, result := range cbo.ComputeCBO(classes, cbo.DefaultConfig()) {
if result.CouplingCount == 0 {
continue
}
```
`coupling.Classes` is what the summary counts (`polyscan/internal/report/report.go:287`, `TotalClasses: src.Summary.TotalClasses`), and that count becomes the coupling-penalty denominator in `core/domain/scoring.go`:
```go
weightedProblematicClasses := float64(highCouplingClasses) + (CouplingMediumWeight * float64(mediumCouplingClasses))
ratio := weightedProblematicClasses / float64(totalClasses)
penalty := ratio / CouplingSaturationRatio * 20.0
```
The parameter is named `totalClasses` and the doc comment describes "the weighted ratio of problematic classes" — it expects the analyzed population, not the coupled subset.
Note the guard `if totalClasses <= 0 { return 0 }` is what produces the 100 in the first row: a package with *no* coupled type is scored perfect rather than unmeasured, which is the same defect seen from the other side.
## Scope
- **Affected**: the generic engine — Go, Rust, C++.
- **Not affected**: JS/TS. `polyscan/internal/js/service/cbo_service.go:169` filters on `req.ShowZeros != nil && !*req.ShowZeros`, and the analyze path builds a bare `domain.CBORequest{Paths: files}` (`polyscan/internal/js/js.go:271`) leaving `ShowZeros` nil, so zeros survive. Verified: a 10-file JS fixture with 9 zero-coupling modules reports `cbo_distribution: {"0": 9, "10+": 1}` and coupling 75/100.
This is worth knowing because `DefaultCBORequest()` (`polyscan/internal/js/domain/cbo.go:160`) *does* set `ShowZeros: BoolPtr(false)`. It currently has no non-test caller, but any consumer that adopts the documented default constructor would pull the JS path into the same bug — the filtered slice already feeds `SummarizeCoupling` → `TotalClasses` → `summary.CBOClasses` (`polyscan/internal/js/service/output_formatter.go:332`).
## Suggested fix
Keep the zero-coupling exclusion for what is *listed* — it is reasonable not to print hundreds of uncoupled types — but count them in the population the score divides by: carry the full `ComputeCBO` result length (or an explicit `TotalTypesAnalyzed`) through to the summary and pass that as `totalClasses`.
Worth pinning with a regression test: **a presentation filter must not change `health_score`.**
## Related
Same class as #93 (dead-code score denominator), opposite direction — there the denominator was too large, here it is too small. #96 tuned `CouplingSaturationRatio`'s sibling for complexity; this one saturates for a different reason and widening the ratio would not fix it.
The Python sibling of this bug is ludo-technologies/pyscn#785, where the equivalent filtering is config-driven (`show_zeros`) and moved the grade by two steps.
polyscan version: `polyscan/polyscan --version` at the current checkout.
Found via the polyscan-fp-audit skill's score-integrity differential step.
Contributor guide
Research direction
Start with polyscan/internal/analysis/coupling.go:317-320, then trace how coupling.Classes reaches polyscan/internal/report/report.go:287 and core/domain/scoring.go. Run the Go fixture with polyscan analyze --format json and compare the reported population and health score before and after the change. Done means zero-coupling types remain excluded from presentation without changing the score denominator; add a regression test for that invariant.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, go, rust
- Domain
- devtools, tooling
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100