Skip to content

Commit 9c6b6be

Browse files
feat(health): error-handling maintainability biomarker (#453)
Surface swallowed-exception and unsafe-unwrap anti-patterns as a bounded maintainability finding: empty/trivial catch or except bodies across Python/JS/TS/Java/Kotlin/C#/C++, Python catch-all except, Rust unwrap/expect/panic-family macros, and Go empty if-err blocks or blank-identifier discards of a call result. - complexity walker: whole-tree anti-pattern pass emitting per-hit line anchors (precision-first; unsupported language or parse failure yields no signal, never a guess) - new error_handling biomarker: one LOW finding per occurrence - scoring: own error_handling category capped at 0.5 with a floored 0.5 weight (0.15 per finding), so the deduction is advisory and can never squeeze the calibrated predictive categories; deliberately excluded from the defect-weight calibration roster - 24 detector fixtures across 11 languages ported as unit tests, plus scoring snapshot updates and doc updates Validated by re-scoring the cached 21-repo / 9-language T0 defect benchmark with the new findings: corpus AUC delta +0.0000 [-0.0036, +0.0037] under keyword labels and +0.0004 [-0.0037, +0.0038] under SZZ labels (neutral by design), no per-language regression beyond noise, max per-file score movement exactly the 0.5 cap. Health perf gate and full unit suite pass.
1 parent e57de6f commit 9c6b6be

13 files changed

Lines changed: 547 additions & 7 deletions

File tree

docs/CODE_HEALTH.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,9 @@ most:
3535
| Size & complexity | −1.5 | complex_method, large_method, primitive_obsession |
3636
| Duplication | −1.0 | dry_violation |
3737
| Test quality | −0.5 | large_assertion_block, duplicated_assertion_block |
38+
| Error handling | −0.5 | error_handling |
3839

39-
Twenty-five biomarkers across the categories above. `function_hotspot` and
40+
Twenty-six biomarkers across the categories above. `function_hotspot` and
4041
`code_age_volatility` are blame-based and sit in the organizational bucket —
4142
both are tier-aware and stay silent on ESSENTIAL-tier repos until the per-line
4243
blame index is built.
@@ -227,6 +228,19 @@ that overlap an assertion block on a test file. A change to the asserted
227228
behaviour then has to be edited in several places — and usually isn't, so the
228229
copies drift.
229230

231+
**error_handling** — Swallowed-exception and unsafe-unwrap anti-patterns: an
232+
empty or comment-only `catch`/`except` body, a Python catch-all `except:` /
233+
`except Exception:`, Rust `.unwrap()` / `.expect()` / `panic!`-family macros,
234+
and Go's empty `if err != nil {}` or blank-identifier discard of a call's
235+
error. Detection is precision-first — only the unambiguous shapes fire, and an
236+
unsupported language or parse failure yields no signal rather than a guess.
237+
Each occurrence is a LOW finding anchored to its line, and the whole category
238+
is capped at −0.5 per file: this is an advisory maintainability flag (every
239+
linter is expected to surface `except: pass`), deliberately not a calibrated
240+
defect predictor — on the 21-repo benchmark it is AUC-neutral, so it is
241+
excluded from the weight calibration and bounded so it can never move a file's
242+
score by more than half a point.
243+
230244
## Test coverage
231245

232246
Pass coverage reports straight into the analyzer:
@@ -332,7 +346,7 @@ Health: 7.4 (avg) · 6.2 (hotspots) · 2.1 (worst: payments/processor.ts)
332346

333347
| Feature | Repowise | CodeScene | DeepSource | Sourcery |
334348
|----------------------------------|:--:|:--:|:--:|:--:|
335-
| Code health score (1–10) |25 biomarkers | ✅ 25–30 |||
349+
| Code health score (1–10) |26 biomarkers | ✅ 25–30 |||
336350
| Brain Method detection |||||
337351
| Low cohesion (LCOM4) / god class |||||
338352
| Test coverage intelligence | ✅ LCOV/Cobertura/Clover/JSON ||||

docs/architecture/code-health.md

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,8 @@ analysis/health/
110110
├── ownership_risk.py
111111
├── churn_risk.py
112112
├── change_entropy.py
113-
└── co_change_scatter.py
113+
├── co_change_scatter.py
114+
└── error_handling.py
114115
```
115116

116117
### Persistence
@@ -317,7 +318,7 @@ higher than dormant ones.
317318

318319
---
319320

320-
## 5. The 25 biomarkers and their categories
321+
## 5. The 26 biomarkers and their categories
321322

322323
Each biomarker is a stateless class implementing the `Biomarker` Protocol
323324
from `biomarkers/base.py`:
@@ -338,6 +339,7 @@ class Biomarker(Protocol):
338339
| Size & complexity | −1.5 | complex_method, large_method, primitive_obsession |
339340
| Duplication | −1.0 | dry_violation |
340341
| Test quality | −0.5 | large_assertion_block, duplicated_assertion_block |
342+
| Error handling | −0.5 | error_handling |
341343

342344
`large_assertion_block` and `duplicated_assertion_block` are the two
343345
**test-quality** smells (see §5.3). They fire only on test files and sit in
@@ -431,6 +433,23 @@ references (a static utility, or an unmapped language) reports `lcom4 = 1`
431433
turn signal on — never produce a false-positive flood. See
432434
`complexity/README.md` for the full heuristic and its limits.
433435

436+
`error_handling` is the **advisory maintainability** biomarker: swallowed
437+
catches (an empty/comment-only `catch` / `except: pass` body), Python
438+
catch-all `except:` / `except Exception:`, Rust `.unwrap()` / `.expect()` /
439+
panic-family macros, and Go's empty `if err != nil {}` or blank-identifier
440+
discard of a call's error. The walker collects each occurrence (with its
441+
line) in a whole-tree pass — module-level code included — reusing the
442+
`LanguageNodeMap` catch kinds for the seven catch-shaped languages and
443+
dedicated recognizers for Rust/Go; an unsupported language or parse failure
444+
yields no hits ("no signal", never a guess). The biomarker emits one LOW
445+
finding per occurrence (0.15 after its floored 0.5 weight) in its own
446+
`error_handling` category capped at −0.5, mirroring `test_quality`'s
447+
advisory framing. It is deliberately **excluded from the defect-weight
448+
calibration**: on the 21-repo / 9-language T0 benchmark it is AUC-neutral
449+
(OOF delta ≈ 0, CI crosses zero) but size-orthogonal and the least redundant
450+
signal tested, and it ships because users expect `except: pass` flagged —
451+
bounded so it can never move a file by more than half a point.
452+
434453
### 5.3 Assertion-block walker metrics (test-quality)
435454

436455
The same single walker pass records `assertion_blocks` on each

packages/core/src/repowise/core/analysis/health/biomarkers/README.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class Biomarker(Protocol):
99
def detect(self, ctx: FileContext) -> list[BiomarkerResult]: ...
1010
```
1111

12-
## Registered detectors (24)
12+
## Registered detectors (26)
1313

1414
Structural complexity (cap −2.5):
1515
- `brain_method` — symbols simultaneously long, complex, and central. The
@@ -83,6 +83,20 @@ Test quality (cap −0.5, test files only):
8383
- `duplicated_assertion_block` — copy-pasted assertion runs across tests,
8484
found by intersecting the clone detector with assertion spans.
8585

86+
Error handling (cap −0.5, own category `error_handling`):
87+
- `error_handling` — swallowed-exception / unsafe-unwrap anti-patterns: an
88+
empty or trivial `catch`/`except` body (Python/JS/TS/Java/Kotlin/C#/C++), a
89+
Python catch-all `except:` / `except Exception:`, Rust `.unwrap()` /
90+
`.expect()` / panic-family macros, and Go's empty `if err != nil {}` or
91+
blank-identifier discard of a call's error. One LOW finding per occurrence
92+
(0.15 after the floored 0.5 weight), bounded at 0.5/file by the category
93+
cap. **A maintainability flag, not a defect predictor** — AUC-neutral on
94+
the 21-repo T0 benchmark and deliberately excluded from the defect
95+
calibration roster; it ships because developers expect a health tool to
96+
flag `except: pass`. Detection is precision-first (unambiguous shapes only;
97+
unsupported language / parse failure ⇒ no signal) and runs as a whole-tree
98+
pass in the complexity walker, so module-level code is covered too.
99+
86100
Caps were recalibrated to lift `organizational` (was −1.0) and de-rate
87101
`size_and_complexity` / `duplication` per plan §3.1. A per-biomarker
88102
weight multiplier in `scoring._BIOMARKER_WEIGHT_MULTIPLIER` lets the
@@ -110,6 +124,8 @@ table alone would allow.
110124
graph; `None` on test fixtures that didn't construct a graph.
111125
- `repo_commit_counts``dict[path, commit_count_total]` populated once
112126
per `analyze` call so co-change detectors can look up partner totals.
127+
- `error_handling_hits``list[ErrorHandlingHit]` (kind + line) from the
128+
walker's whole-tree anti-pattern pass; empty means "no signal".
113129

114130
## Outputs
115131

packages/core/src/repowise/core/analysis/health/biomarkers/base.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, Protocol
77

88
from ....ingestion.git_indexer.function_blame import BlameIndex
9-
from ..complexity import ClassComplexity, FunctionComplexity
9+
from ..complexity import ClassComplexity, ErrorHandlingHit, FunctionComplexity
1010
from ..duplication import ClonePair
1111
from ..models import Severity
1212

@@ -95,6 +95,12 @@ class FileContext:
9595
# ownership findings are downgraded to informational severity unless
9696
# corroborated (issue #361).
9797
repo_active_contributors_90d: int | None = None
98+
# Error-handling anti-pattern occurrences (swallowed catch, bare
99+
# except, unsafe unwrap, discarded Go error) collected by the
100+
# complexity walker's whole-tree pass. Empty when the language is
101+
# unsupported or parsing failed — the documented "no signal" outcome.
102+
# Consumed by the ``error_handling`` biomarker.
103+
error_handling_hits: list[ErrorHandlingHit] = field(default_factory=list)
98104

99105

100106
# A repo whose trailing-90-day window has at most this many active human
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
"""Error Handling — swallowed-exception / unsafe-unwrap anti-patterns.
2+
3+
Surfaces the error-handling smells every linter is expected to flag: an
4+
empty ``catch`` / ``except: pass``, a Python catch-all ``except:``, a
5+
Rust ``.unwrap()`` / ``panic!``, a Go error checked-then-ignored or
6+
discarded via the blank identifier. One LOW finding per occurrence, each
7+
anchored to its line.
8+
9+
This is a bounded maintainability signal, NOT a defect predictor: on the
10+
21-repo / 9-language T0 benchmark it is AUC-neutral (OOF delta ~0, CI
11+
crosses zero) but size-orthogonal and the least redundant signal tested
12+
(max |rho| ~0.21 vs the calibrated roster; churn rho ~0.02). It therefore
13+
ships in its own ``error_handling`` category with an advisory 0.5 cap and
14+
a floored 0.5 weight, and is excluded from the defect-calibration roster.
15+
16+
Detection happens in the complexity walker's whole-tree pass (see
17+
``complexity.walker._collect_error_handling``); this detector just lifts
18+
the pre-collected hits into findings. Precision-first: unsupported
19+
languages and parse failures yield zero hits, never a false positive.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
from ..models import Severity
25+
from .base import BiomarkerResult, FileContext
26+
27+
_REASONS: dict[str, str] = {
28+
"swallowed_catch": "caught exception is swallowed without any handling",
29+
"bare_except": "catch-all except hides every error, including KeyboardInterrupt",
30+
"unsafe_unwrap": "unwrap/expect/panic turns a recoverable error into a crash",
31+
"go_swallow": "error value is checked then ignored, or discarded via the blank identifier",
32+
}
33+
34+
35+
class ErrorHandlingDetector:
36+
name = "error_handling"
37+
category = "error_handling"
38+
39+
def detect(self, ctx: FileContext) -> list[BiomarkerResult]:
40+
out: list[BiomarkerResult] = []
41+
for hit in ctx.error_handling_hits:
42+
out.append(
43+
BiomarkerResult(
44+
biomarker_type=self.name,
45+
severity=Severity.LOW,
46+
function_name=None,
47+
line_start=hit.line,
48+
line_end=hit.line,
49+
details={"kind": hit.kind},
50+
reason=_REASONS.get(hit.kind, hit.kind),
51+
)
52+
)
53+
return out
54+
55+
56+
BIOMARKER = ErrorHandlingDetector()

packages/core/src/repowise/core/analysis/health/biomarkers/registry.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
from .developer_congestion import DeveloperCongestionDetector
2626
from .dry_violation import DryViolationDetector
2727
from .duplicated_assertion_block import DuplicatedAssertionBlockDetector
28+
from .error_handling import ErrorHandlingDetector
2829
from .function_hotspot import FunctionHotspotDetector
2930
from .god_class import GodClassDetector
3031
from .hidden_coupling import HiddenCouplingDetector
@@ -64,6 +65,7 @@
6465
PriorDefectDetector, # type: ignore[list-item]
6566
LargeAssertionBlockDetector, # type: ignore[list-item]
6667
DuplicatedAssertionBlockDetector, # type: ignore[list-item]
68+
ErrorHandlingDetector, # type: ignore[list-item]
6769
]
6870

6971

packages/core/src/repowise/core/analysis/health/complexity/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .walker import (
66
ClassComplexity,
77
ConditionComplexity,
8+
ErrorHandlingHit,
89
FileComplexity,
910
FunctionComplexity,
1011
walk_file,
@@ -14,6 +15,7 @@
1415
__all__ = [
1516
"ClassComplexity",
1617
"ConditionComplexity",
18+
"ErrorHandlingHit",
1719
"FileComplexity",
1820
"FunctionComplexity",
1921
"walk_file",

0 commit comments

Comments
 (0)