Repository navigation
Source-less external placeholders that lose their last edge without a file being deleted are never collected (after #4247 upgrade, and on every edit that drops the last reference) #4399
Description
Pre-flight checks
- I have checked the Troubleshooting section in the README
What happened?
A source-less node (external-type placeholder such as List, or a namespace node such as system_collections_generic) that loses its last edge stays in graph.json permanently at degree 0. This happens when the edge goes away because a file was re-extracted, not deleted.
There are two paths, both on 0.9.88:
- Upgrade across [Bug]: C#: an external type referenced from several files gets one placeholder node per file #4247 (0.9.84). The per-file stubs (
a_cs_list,b_cs_list) are replaced by one sharedlistplaceholder. Re-extraction re-points the edges, but the old stubs are left behind at degree 0.- This happens with incremental runs and with
extract --force --code-only.--code-onlyon an existing graph always merges (merge_existing_graph = incremental_mode or (code_only and existing_graph_path.exists()), cli.py:3737). - On our real C# backend graph, a
--force --code-onlyrebuild after 0.9.70 → 0.9.88 left 41,709 orphans out of 116,039 nodes. For example, there were 4,233Tasknodes at degree 0 next to one sharedtasknode with 12,458 edges. - Communities went from 13,249 to 43,319, almost all of them singletons.
query_graph/get_nodeforTask/Listreturned thousands of dead hits.
- This happens with incremental runs and with
- Steady state. When the last file that references an external type or namespace is edited so that it no longer references it, the shared placeholder stays at degree 0 forever. This is ongoing accumulation, not just a one-off migration artefact.
As far as I can tell from the 0.9.88 source, the cause is:
build_mergedrops existing nodes and edges owned by re-extracted sources (bysource_file, build.py ~2220). A source-less placeholder is owned by nobody, so it survives with no edges.- The orphan sweep added for Pruning a source file leaves its external-import nodes behind as permanent orphans: no source_file means no prune can ever match them #2807 (build.py ~2483–2531) only runs inside
if prune_sources:, so it only runs when files are deleted or excluded. - Even when it does run, it spares
_isolated_before: source-less nodes that are already at degree 0 when the sweep starts. That is computed after the re-extracted sources' old edges are gone, so these nodes count as "already isolated" and are kept.
--force does not help (see path 1). The only way to clear them is to delete graph.json and rebuild from scratch, which loses the semantic/curated layer. Alternatively you can prune by hand.
Steps to reproduce
Path 1 (upgrade):
mkdir -p repro/src && cd repro
printf 'using System.Collections.Generic;\nnamespace Demo\n{\n public class A { public List<int> Items; }\n}\n' > src/A.cs
printf 'using System.Collections.Generic;\nnamespace Demo\n{\n public class B { public List<string> Names; }\n}\n' > src/B.cs
uvx --from "graphifyy==0.9.70" graphify extract src --code-only
uvx --from "graphifyy==0.9.88" graphify extract src --force --code-onlyPath 2 (steady state, 0.9.88 only):
rm -rf src/graphify-out
uvx --from "graphifyy==0.9.88" graphify extract src --code-only # fresh build
printf 'namespace Demo\n{\n public class A { public int[] Items; }\n}\n' > src/A.cs
printf 'namespace Demo\n{\n public class B { public string[] Names; }\n}\n' > src/B.cs
uvx --from "graphifyy==0.9.88" graphify extract src --code-only # incremental: both files edited, nothing deletedError output or graph output
Source-less nodes and their degree after each step:
Path 1
0.9.70 extract nodes 8 a_cs_list(1) b_cs_list(1) system_collections_generic(2)
0.9.88 extract --force --code-only nodes 9 list(2) a_cs_list(0) b_cs_list(0) system_collections_generic(2)
0.9.88 incremental (touch A.cs) nodes 9 list(2) a_cs_list(0) b_cs_list(0) system_collections_generic(2)
control: fresh 0.9.88 from scratch nodes 7 list(2) system_collections_generic(2)
Path 2
fresh 0.9.88 nodes 7 list(2) system_collections_generic(2)
incremental, A+B drop List nodes 7 list(0) system_collections_generic(0)
control: fresh from scratch nodes 5 (no source-less nodes)
Expected: incremental and --force --code-only end with the same node set as a from-scratch build (7 and 5 respectively).
Graphify version
0.9.88 (upgraded from 0.9.70)
Operating System
openSUSE Leap 16.0 (x86_64)
Python Version
3.13.13
Installation Method
uv tool install "graphifyy[openai,mcp]" (uv 0.11.16); repro uses uvx
Additional Environment Details
Large C# monorepo (~430 csproj, ~116k nodes before cleanup). The graph is served through graphify serve, rebuilt incrementally every night and with --force --code-only once a week.
Additional context
Possible fix directions (your call, of course):
- Run the source-less/degree-0 sweep after every merge, not only when
prune_sourcesis non-empty. - Snapshot
_isolated_beforefrom the graph as loaded from disk (before the re-extracted sources' nodes and edges are dropped). Nodes that were isolated in the stored graph would still be protected, which is the rationale in the code comment. Nodes isolated by this run's rebind would be collected, matching the Pruning a source file leaves its external-import nodes behind as permanent orphans: no source_file means no prune can ever match them #2807 reasoning that "a node with neither a source_file nor an edge names nothing and connects nothing". - Optionally, a one-time migration sweep when the stored
graphify_versionpredates [Bug]: C#: an external type referenced from several files gets one placeholder node per file #4247, for graphs whose stubs were already orphaned by an earlier run.
For now we pruned our graph once by hand with exactly the #2807 criterion (no source_file, file_type == "code", degree 0), then ran cluster-only + label. Result: 116,039 → 74,330 nodes, edges unchanged, 43,319 → 1,610 communities. Path 2 means the orphans will build up again without a fix upstream.
Activity
github-actions commentedon Oct 13, 2026
Thanks for opening this issue, @janmaticka. A maintainer will take a look soon.
If you would like to discuss it in real time, come say hi on our Discord server. For longer-form questions and ideas there is also GitHub Discussions.
To help us triage, please make sure the report includes what you expected, what actually happened, and the steps (and a small sample) to reproduce it.
fix(build): spare hyperedge members and pre-prune semantic isolates i…
fix(build): spare hyperedge members and pre-prune semantic isolates i…
fix(build): sweep source-less placeholders orphaned by re-extraction (G…