From 4cf3dfba06c495e1c94f31a7ce10efb7af29260a Mon Sep 17 00:00:00 2001 From: nothariharan Date: Thu, 8 Oct 2026 08:20:08 +0530 Subject: [PATCH] fix(analyze): exclude contains-only AST declarations from knowledge gap detection (#4205) (cherry picked from commit bf55b069f8dfaa830f37474919156d281b66b1e6) --- graphify/analyze.py | 15 +++++++ graphify/report.py | 7 +++- tests/test_report_gap_thresholds.py | 65 +++++++++++++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/graphify/analyze.py b/graphify/analyze.py index ef4154ed5..b44116f67 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -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 @@ -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: diff --git a/graphify/report.py b/graphify/report.py index 168197d54..65db33c13 100644 --- a/graphify/report.py +++ b/graphify/report.py @@ -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/ @@ -324,7 +324,10 @@ def generate( ] # --- 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 diff --git a/tests/test_report_gap_thresholds.py b/tests/test_report_gap_thresholds.py index 45ca83d67..b9001dad3 100644 --- a/tests/test_report_gap_thresholds.py +++ b/tests/test_report_gap_thresholds.py @@ -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