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
15 changes: 15 additions & 0 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,20 @@ def _is_concept_node(G: nx.Graph, node_id: str) -> bool:
return False


def _is_contains_only_ast_node(G: nx.Graph, node_id: str) -> bool:
"""AST declarations (type aliases, enum members, local consts, JSON keys)
whose only edge is the structural `contains` from their declaring file are
working as designed, not documentation gaps (#4205)."""
from graphify.build import _is_ast_tier
attrs = G.nodes[node_id]
if not _is_ast_tier(attrs):
return False
if G.degree(node_id) != 1:
return False
edges = list(G.edges(node_id, data=True))
return len(edges) == 1 and edges[0][2].get("relation") == "contains"


from graphify.detect import CODE_EXTENSIONS, DOC_EXTENSIONS, PAPER_EXTENSIONS, IMAGE_EXTENSIONS


Expand Down Expand Up @@ -552,6 +566,7 @@ def suggest_questions(
if G.degree(n) <= 1
and not _is_file_node(G, n)
and not _is_concept_node(G, n)
and not _is_contains_only_ast_node(G, n)
and G.nodes[n].get("file_type") != "rationale"
]
if isolated:
Expand Down
7 changes: 5 additions & 2 deletions graphify/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ def generate(
f"the graph (top: {top})"
)

from .analyze import _is_file_node, _is_concept_node
from .analyze import _is_file_node, _is_concept_node, _is_contains_only_ast_node

def _real_node(n) -> bool:
# The one definition of "real" (structural, not file/concept/
Expand Down Expand Up @@ -324,7 +324,10 @@ def _real_count(nodes) -> int:
]

# --- Gaps section ---
isolated = [n for n in G.nodes() if G.degree(n) <= 1 and _real_node(n)]
isolated = [
n for n in G.nodes()
if G.degree(n) <= 1 and _real_node(n) and not _is_contains_only_ast_node(G, 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
Expand Down
65 changes: 65 additions & 0 deletions tests/test_report_gap_thresholds.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,3 +168,68 @@ def test_community_listing_excludes_rationale_and_concept_nodes():
assert "Nodes (3):" in text, text
assert "Names exist largely" not in text
assert "Some Concept" not in text


def test_contains_only_ast_declarations_are_not_knowledge_gaps():
"""#4205: TS type aliases / enum members / local consts whose only edge
is the structural contains edge from their own file are working as
designed, so they must not be reported as weakly-connected gaps."""
G = nx.Graph()
G.add_node("tabs", label="tabs.ts", file_type="code", source_file="src/tabs.ts")
for name in ("TabRow", "ContributionRow", "TabRecognition"):
G.add_node(name, label=name, file_type="code",
source_file="src/tabs.ts", source_location="L1",
_origin="ast")
G.add_edge("tabs", name, relation="contains", confidence="EXTRACTED")
communities = {0: ["tabs", "TabRow", "ContributionRow", "TabRecognition"]}

text = generate(
G, communities, {}, {}, [], [],
{"total_files": 1, "total_words": 10}, {},
root="proj", min_community_size=1,
)
gaps = text.split("## Knowledge Gaps")[-1].split("## ")[0] if "## Knowledge Gaps" in text else ""
assert "TabRow" not in gaps
assert "ContributionRow" not in gaps

from graphify.analyze import suggest_questions
qs = suggest_questions(G, communities, {})
assert all(q["type"] != "isolated_nodes" for q in qs)


def test_semantic_contains_only_node_still_flags_as_gap():
"""The #4205 fix must not hide the semantic side of gap detection: a
non-AST node whose only edge is contains stays isolated."""
G = nx.Graph()
G.add_node("doc", label="guide.md", file_type="document", source_file="docs/guide.md")
G.add_node("concept", label="OnboardingChecklist", file_type="document",
source_file="docs/guide.md", source_location=None, _origin="semantic")
G.add_edge("doc", "concept", relation="contains", confidence="EXTRACTED")
communities = {0: ["doc", "concept"]}

text = generate(
G, communities, {}, {}, [], [],
{"total_files": 1, "total_words": 10}, {},
root="proj", min_community_size=1,
)
gaps = text.split("## Knowledge Gaps")[-1].split("## ")[0] if "## Knowledge Gaps" in text else ""
assert "OnboardingChecklist" in gaps


def test_ast_node_with_non_contains_single_edge_still_flags():
"""The exclusion is specifically for contains-only AST nodes: an AST
node whose one edge is a different relation is still a gap."""
G = nx.Graph()
G.add_node("a", label="a.ts", file_type="code", source_file="src/a.ts")
G.add_node("loose", label="LooseHelper", file_type="code",
source_file="src/a.ts", source_location="L1", _origin="ast")
G.add_edge("a", "loose", relation="calls", confidence="AMBIGUOUS")
communities = {0: ["a", "loose"]}

text = generate(
G, communities, {}, {}, [], [],
{"total_files": 1, "total_words": 10}, {},
root="proj", min_community_size=1,
)
gaps = text.split("## Knowledge Gaps")[-1].split("## ")[0] if "## Knowledge Gaps" in text else ""
assert "LooseHelper" in gaps
Loading