Skip to content

Commit c8cfd69

Browse files
committed
fix(detectors): FP cuts + empty-state Pattern-2 envelope batch
shotgun-surgery retuned to distinct-non-test-caller-file scatter (big FP cut); impact blast-radius affected-total + cap disclosure + metric definition; forecast + coverage-gaps empty-corpus Pattern-2 envelopes; understand<->health band-label parity via new quality/health_band.py; fan test/prod split.
1 parent c935bc4 commit c8cfd69

16 files changed

Lines changed: 1477 additions & 102 deletions

src/roam/catalog/smells.py

Lines changed: 89 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -441,21 +441,93 @@ def detect_feature_envy(conn: sqlite3.Connection) -> list[dict]:
441441
return results
442442

443443

444-
# Tier: structural — in_degree comes from the precomputed graph_metrics
445-
# table; threshold over a graph-edge count, no name heuristic.
444+
# Tier: structural — caller-FILE scatter over the call-graph edges
445+
# (distinct source files of incoming edges), not a name heuristic. The
446+
# signal is graph topology, so the tier stays structural.
447+
#
448+
# W1287 RE-IMPLEMENTATION (dev/NEXT-FP-TARGET-2026-05-20.md): the pre-W1287
449+
# detector fired on ``graph_metrics.in_degree > 7`` — pure INBOUND
450+
# popularity. An 18-sample dogfood measured ~100% FP / 0 TP: the top hits
451+
# were the codebase's BEST shared symbols (conftest fixtures invoke_cli/
452+
# cli_runner, helpers open_db/json_envelope/to_json, dataclass field
453+
# EvidenceArtifact.path), 69% in tests/. High inbound reference count is
454+
# good factoring — the OPPOSITE of the smell. The module comment at the
455+
# ``message-chain`` registration confirms message-chain is the out_degree
456+
# axis, so the in_degree impl was the wrong axis entirely.
457+
#
458+
# Fowler's shotgun surgery = ONE change forcing edits across MANY DISTINCT
459+
# FILES — file-SCATTER, not raw reference count. The metric is now the
460+
# count of DISTINCT NON-TEST CALLER FILES referencing the symbol. To keep
461+
# the detector from re-discovering the same well-factored-hub FP class
462+
# (re-measurement on roam-code at threshold 8 still surfaced to_json /
463+
# open_db / ensure_index / find_project_root — single-source-of-truth
464+
# utilities whose high scatter is *centralization*, not surgery; estimated
465+
# new FP still > 60%), the detector is deliberately CONSERVATIVE: it fires
466+
# rarely-but-precisely behind (1) a high threshold and (2) the same
467+
# test-role / property / trivial-accessor exclusions the W1280 feature-envy
468+
# fix used. A well-factored repo SHOULD report ~zero shotgun-surgery rows;
469+
# the conservative gate makes that the expected state rather than emitting
470+
# ~1472 popularity-artifact FPs. The kind stays registered (retiring it
471+
# triggers count-drift / registry ripple). Tune ``_SHOTGUN_MIN_CALLER_FILES``
472+
# up, never down, if a future corpus re-introduces FPs.
473+
_SHOTGUN_MIN_CALLER_FILES = 12
474+
475+
446476
@detector("shotgun-surgery", confidence=CONFIDENCE_STRUCTURAL)
447477
def detect_shotgun_surgery(conn: sqlite3.Connection) -> list[dict]:
448-
"""Symbols with in_degree > 7 in graph_metrics."""
478+
"""Symbols referenced from many DISTINCT non-test caller files.
479+
480+
Fires when a function/method is referenced (incoming call/use edges)
481+
from at least ``_SHOTGUN_MIN_CALLER_FILES`` (12) DISTINCT files that
482+
are NOT the symbol's own file and NOT test-role files — i.e. changing
483+
this one symbol ripples across many separate files (Fowler's shotgun
484+
surgery: file-SCATTER, not raw inbound-reference count). Excludes
485+
test-role target files, ``@property`` / dataclass-field symbols, and
486+
trivial 1-3 line accessors (mirrors the W1280 feature-envy exclusion
487+
style). Deliberately conservative — see the W1287 comment above for
488+
why high inbound popularity alone is NOT this smell.
489+
"""
449490
rows = conn.execute(
450-
"SELECT s.name, s.kind, s.line_start, f.path as file_path, "
451-
"gm.in_degree "
491+
"SELECT s.id, s.name, s.kind, s.line_start, s.line_end, s.file_id, "
492+
"s.decorators, f.path as file_path "
452493
"FROM symbols s "
453494
"JOIN files f ON s.file_id = f.id "
454-
"JOIN graph_metrics gm ON gm.symbol_id = s.id "
455-
"WHERE gm.in_degree > 7"
495+
"WHERE s.kind IN ('function', 'method')"
456496
).fetchall()
457497
results = []
458498
for r in rows:
499+
# (1) Skip test-role target files.
500+
if _is_test_path(r["file_path"]):
501+
continue
502+
# (2) Skip @property / dataclass-field symbols and trivial 1-3 line
503+
# accessors — their high scatter is by-design surface, not the
504+
# ripple-on-change signal (mirrors W1280 feature-envy exclusions).
505+
dec = r["decorators"] or ""
506+
if "@property" in dec or "@cached_property" in dec:
507+
continue
508+
ls, le = r["line_start"], r["line_end"]
509+
if ls is not None and le is not None and (le - ls) <= 2:
510+
continue
511+
# (3) Count DISTINCT non-test caller files (incoming edges), excluding
512+
# the symbol's own file. This is the file-SCATTER metric: how many
513+
# separate files a change to this symbol would ripple across.
514+
caller_rows = conn.execute(
515+
"SELECT DISTINCT COALESCE(e.source_file_id, ss.file_id) AS cf, "
516+
"cf2.path AS cpath "
517+
"FROM edges e "
518+
"JOIN symbols ss ON e.source_id = ss.id "
519+
"JOIN files cf2 ON cf2.id = COALESCE(e.source_file_id, ss.file_id) "
520+
"WHERE e.target_id = ?",
521+
(r["id"],),
522+
).fetchall()
523+
caller_files = {
524+
cr["cpath"]
525+
for cr in caller_rows
526+
if cr["cf"] is not None and cr["cf"] != r["file_id"] and not _is_test_path(cr["cpath"])
527+
}
528+
scatter = len(caller_files)
529+
if scatter < _SHOTGUN_MIN_CALLER_FILES:
530+
continue
459531
loc_str = _loc(r["file_path"], r["line_start"])
460532
results.append(
461533
_finding(
@@ -464,9 +536,10 @@ def detect_shotgun_surgery(conn: sqlite3.Connection) -> list[dict]:
464536
r["name"],
465537
r["kind"],
466538
loc_str,
467-
r["in_degree"],
468-
7,
469-
f"Shotgun surgery: {r['in_degree']} incoming dependencies",
539+
scatter,
540+
_SHOTGUN_MIN_CALLER_FILES,
541+
f"Shotgun surgery: referenced from {scatter} distinct non-test files; "
542+
f"a change ripples across all of them",
470543
)
471544
)
472545
return results
@@ -1031,9 +1104,12 @@ def detect_low_cohesion(conn: sqlite3.Connection) -> list[dict]:
10311104
return results
10321105

10331106

1034-
# Tier: structural — mirror of ``shotgun-surgery`` on the outgoing axis.
1035-
# out_degree comes from precomputed graph_metrics; threshold over graph
1036-
# topology, no name signal.
1107+
# Tier: structural — out_degree comes from precomputed graph_metrics;
1108+
# threshold over graph topology, no name signal. (Historically labelled the
1109+
# "outgoing-axis mirror of shotgun-surgery"; that framing predated the W1287
1110+
# shotgun-surgery re-implementation onto distinct-caller-FILE scatter, so the
1111+
# mirror note is dropped — message-chain is an out_degree-per-symbol count,
1112+
# shotgun-surgery is now a distinct-caller-file-scatter count; different axes.)
10371113
@detector("message-chain", confidence=CONFIDENCE_STRUCTURAL)
10381114
def detect_message_chain(conn: sqlite3.Connection) -> list[dict]:
10391115
"""Functions with out_degree > 10 in graph_metrics."""

src/roam/commands/cmd_coverage_gaps.py

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,31 +561,59 @@ def coverage_gaps(
561561
gates, gate_info = _find_gates(conn, gate_names, gate_pattern)
562562

563563
if not gates:
564+
# W837 (Pattern 2 empty-state disclosure): the pre-W837 envelope
565+
# carried only ``summary.error`` — no ``verdict`` (LAW 6: the
566+
# verdict must stand alone) and no ``partial_success`` / ``state``
567+
# flag. A consumer reading ``summary.verdict`` saw ``None``,
568+
# indistinguishable from a malformed envelope. On an empty corpus
569+
# (or a gate filter that matches nothing) there is genuinely no
570+
# gate to compute coverage against — disclose that explicitly
571+
# rather than emitting a verdict-less envelope.
572+
verdict = "no gate symbols matched — coverage cannot be computed (broaden --gate / --gate-pattern or index a populated repo)"
564573
if json_mode:
565574
click.echo(
566575
to_json(
567576
json_envelope(
568577
"coverage-gaps",
569-
summary={"error": "No gate symbols found"},
578+
summary={
579+
"verdict": verdict,
580+
"error": "No gate symbols found",
581+
"partial_success": True,
582+
"state": "no_gates",
583+
},
584+
agent_contract={"facts": [verdict]},
570585
)
571586
)
572587
)
573588
else:
589+
click.echo(f"VERDICT: {verdict}")
574590
click.echo("No gate symbols found matching the criteria.")
575591
return
576592

577593
entries = _find_entries(conn, scope, entry_pattern)
578594
if not entries:
595+
# W837 (Pattern 2): mirror the no-gates disclosure for the
596+
# no-entry-points branch. A real gate may exist but an empty /
597+
# entry-point-less corpus has nothing reaching it, so coverage is
598+
# vacuous — name the absent entry points explicitly.
599+
verdict = "no entry points in scope — coverage cannot be computed (widen --scope / --entry-pattern or index a populated repo)"
579600
if json_mode:
580601
click.echo(
581602
to_json(
582603
json_envelope(
583604
"coverage-gaps",
584-
summary={"error": "No entry points found"},
605+
summary={
606+
"verdict": verdict,
607+
"error": "No entry points found",
608+
"partial_success": True,
609+
"state": "no_entries",
610+
},
611+
agent_contract={"facts": [verdict]},
585612
)
586613
)
587614
)
588615
else:
616+
click.echo(f"VERDICT: {verdict}")
589617
click.echo("No entry points found in scope.")
590618
return
591619

0 commit comments

Comments
 (0)