diff --git a/graphify/report.py b/graphify/report.py index 65e1ffada..168197d54 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -153,13 +153,29 @@ def generate( f"the graph (top: {top})" ) - from .analyze import _is_file_node as _ifn + from .analyze import _is_file_node, _is_concept_node + + def _real_node(n) -> bool: + # The one definition of "real" (structural, not file/concept/ + # rationale) every figure this function prints about itself must + # agree on (#3148) -- file nodes are structural scaffolding, concept + # nodes are semantic-layer entities, and rationale nodes are + # docstring fragments the LLM extracted for context, none of which + # are a code declaration a reader would recognize as a "node" in + # this list. A community-listing loop that filtered only file nodes + # let rationale/concept content leak into "Nodes (N): ..." as if it + # were a real declaration, and inflated N by counting it (#3794). + return ( + not _is_file_node(G, n) + and not _is_concept_node(G, n) + and G.nodes[n].get("file_type") != "rationale" + ) def _real_count(nodes) -> int: - return sum(1 for n in nodes if not _ifn(G, n)) + return sum(1 for n in nodes if _real_node(n)) non_empty = {cid: nodes for cid, nodes in communities.items() - if any(not _ifn(G, n) for n in nodes)} + if any(_real_node(n) for n in nodes)} # One predicate for every figure the report prints about itself (#3148): # "thin" is real < min_community_size (ZERO real nodes included), and # "shown" is what the render loop below actually renders (real >= @@ -280,8 +296,9 @@ def generate( for cid, nodes in communities.items(): label = community_labels.get(cid, f"Community {cid}") score = cohesion_scores.get(cid, 0.0) - # Filter method/function stubs from display - they're structural noise - real_nodes = [n for n in nodes if not _ifn(G, n)] + # Filter file/concept/rationale nodes from display - they're + # structural noise or docstring prose, not a code declaration (#3794). + real_nodes = [n for n in nodes if _real_node(n)] if not real_nodes: continue if len(real_nodes) < min_community_size: @@ -307,15 +324,7 @@ def generate( ] # --- Gaps section --- - from .analyze import _is_file_node, _is_concept_node - - isolated = [ - n for n in G.nodes() - if G.degree(n) <= 1 - and not _is_file_node(G, n) - and not _is_concept_node(G, n) - and G.nodes[n].get("file_type") != "rationale" - ] + isolated = [n for n in G.nodes() if G.degree(n) <= 1 and _real_node(n)] # Same threshold the Summary and Communities headers used (#3148): this # was a hardcoded 3, so with --min-community-size anything else the count # here disagreed with the label text beside it, which already printed @@ -324,7 +333,7 @@ def generate( # as a genuinely thin one, so it belongs in this count too. thin_communities = { cid: nodes for cid, nodes in communities.items() - if sum(1 for n in nodes if not _is_file_node(G, n)) < min_community_size + if _real_count(nodes) < min_community_size } gap_count = len(isolated) + len(thin_communities)