Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 25 additions & 4 deletions graphify/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,13 +139,25 @@ def generate(
]

from .analyze import _is_file_node as _ifn

def _real_count(nodes) -> int:
return sum(1 for n in nodes if not _ifn(G, n))

non_empty = {cid: nodes for cid, nodes in communities.items()
if any(not _ifn(G, n) for n in nodes)}
# One predicate for every figure the report prints about itself (#3148):
# "thin" is 0 < real < min_community_size, and "shown" is what the render
# loop below actually renders (real >= min_community_size) - previously
# shown was total-thin, which also counted communities with ZERO real
# nodes that the loop skips, overstating the count (#2129's residual).
thin_count_summary = sum(
1 for nodes in communities.values()
if 0 < sum(1 for n in nodes if not _ifn(G, n)) < min_community_size
if 0 < _real_count(nodes) < min_community_size
)
shown_count = sum(
1 for nodes in communities.values()
if _real_count(nodes) >= min_community_size
)
shown_count = len(communities) - thin_count_summary

lines += [
"",
Expand Down Expand Up @@ -284,9 +296,13 @@ def generate(
and not _is_concept_node(G, n)
and G.nodes[n].get("file_type") != "rationale"
]
# 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
# min_community_size.
thin_communities = {
cid: nodes for cid, nodes in communities.items()
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < 3
if 0 < sum(1 for n in nodes if not _is_file_node(G, n)) < min_community_size
}
gap_count = len(isolated) + len(thin_communities)

Expand All @@ -295,8 +311,13 @@ def generate(
if isolated:
isolated_labels = [G.nodes[n].get("label", n) for n in isolated[:5]]
suffix = f" (+{len(isolated)-5} more)" if len(isolated) > 5 else ""
raw_isolated = sum(1 for n in G.nodes() if G.degree(n) <= 1)
lines.append(f"- **{len(isolated)} isolated node(s):** {', '.join(f'`{l}`' for l in isolated_labels)}{suffix}")
lines.append(" These have ≤1 connection - possible missing edges or undocumented components.")
lines.append(
" These have ≤1 connection - possible missing edges or undocumented components. "
f"(Counts symbols only; {raw_isolated} node(s) total have ≤1 connection when "
"file, concept and rationale nodes are included.)"
)
if thin_communities:
lines.append(f"- **{len(thin_communities)} thin communities (<{min_community_size} nodes) omitted from report** — run `graphify query` to explore isolated nodes.")
if amb_pct > 20:
Expand Down
84 changes: 84 additions & 0 deletions tests/test_report_gap_thresholds.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
"""GRAPH_REPORT's headline numbers must agree with themselves (#3148).

The Summary and Communities headers counted thin communities against the
caller's --min-community-size, while the Knowledge Gaps section counted
against a hardcoded 3 - beside label text that already printed
min_community_size. And the "isolated node(s)" figure excludes file,
concept and rationale nodes without saying so, so a plain degree<=1 recount
from graph.json never matched it. Also the #2129 residual: "shown" was
total-minus-thin, which counted zero-real-node communities the render loop
skips.
"""
from __future__ import annotations

import re

import networkx as nx

from graphify.report import generate


def _graph_and_communities():
G = nx.Graph()
# community 0: 6 real symbol nodes (rendered at every threshold used here)
big = [f"big{i}" for i in range(6)]
# community 1: 4 real nodes - thin at min=5, NOT thin at the hardcoded 3
mid = [f"mid{i}" for i in range(4)]
for n in big + mid:
G.add_node(n, label=n, file_type="code", source_file=f"src/{n}.py",
source_location="L1")
for group in (big, mid):
for a, b in zip(group, group[1:]):
G.add_edge(a, b, relation="calls", confidence="EXTRACTED")
# community 2: only a file node - the render loop skips it entirely
G.add_node("onlyfile", label="onlyfile.py", file_type="code", source_file="onlyfile.py")
# a lonely concept node: degree 0, excluded from the symbol-isolated list
G.add_node("lonely_concept", label="Lonely", file_type="concept", source_file="d.md")
communities = {0: big, 1: mid, 2: ["onlyfile"]}
return G, communities


def _report(min_size):
G, communities = _graph_and_communities()
return generate(
G, communities, {}, {}, [], [],
{"total_files": 3, "total_words": 100}, {},
root="proj", min_community_size=min_size,
)


def test_summary_and_gaps_count_thin_with_the_same_threshold():
text = _report(5)
assert "(1 shown, 1 thin omitted)" in text
m = re.search(r"\*\*(\d+) thin communit\w+ \(<(\d+) nodes\) omitted", text)
assert m, text
assert m.group(1) == "1" and m.group(2) == "5", m.group(0)


def test_at_the_default_threshold_nothing_is_thin():
text = _report(3)
assert "0 thin omitted" in text
if "## Knowledge Gaps" in text:
gaps = text.split("## Knowledge Gaps")[-1].split("## ")[0]
assert "thin communit" not in gaps


def test_shown_counts_only_what_the_render_loop_renders():
"""The zero-real-node community is neither shown nor thin (#2129 residual):
shown must be 1 (the big community), not total-minus-thin = 2."""
text = _report(5)
header = re.search(r"## Communities \((\d+) total, (\d+) thin omitted\)", text)
assert header and header.group(2) == "1"
assert "(1 shown, 1 thin omitted)" in text
rendered = len(re.findall(r"### Community ", text))
assert rendered <= 1 or rendered == int(re.search(r"\((\d+) shown", text).group(1))


def test_isolated_count_is_auditable_against_the_raw_graph():
text = _report(3)
assert "isolated node(s):" in text
assert "Counts symbols only" in text
m = re.search(r"(\d+) node\(s\) total have ≤1 connection", text)
assert m
G, _ = _graph_and_communities()
assert int(m.group(1)) == sum(1 for n in G.nodes() if G.degree(n) <= 1)
Loading