Skip to content

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

Closed

Description

@janmaticka

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:

  1. 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 shared list placeholder. 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-only on 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-only rebuild after 0.9.70 → 0.9.88 left 41,709 orphans out of 116,039 nodes. For example, there were 4,233 Task nodes at degree 0 next to one shared task node with 12,458 edges.
    • Communities went from 13,249 to 43,319, almost all of them singletons. query_graph/get_node for Task/List returned thousands of dead hits.
  2. 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_merge drops existing nodes and edges owned by re-extracted sources (by source_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-only

Path 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 deleted

Error 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):

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

  1. github-actions commented on Oct 13, 2026

    @github-actions

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions