@@ -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 )
447477def 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 )
10381114def detect_message_chain (conn : sqlite3 .Connection ) -> list [dict ]:
10391115 """Functions with out_degree > 10 in graph_metrics."""
0 commit comments