Summary
Running the graphify skill's Python AST extraction (graphify.extract.extract_python) on a corpus where a dataclass/class is defined in one file and referenced (imported + used in a type annotation, e.g. a function return type or another dataclass's field type) from a different file produces two separate nodes with the same label — one at the defining file's correct node ID, and one "ghost" duplicate anchored at the referencing file, instead of a single node with an imports/references edge pointing at it.
This inflates degree/centrality metrics (a class can show up as a "god node" partly due to its own duplicate's edges being counted separately) and fragments community detection (each ghost duplicate can end up isolated in its own thin community).
Reproduction
A minimal repro, mirroring what I hit:
# pkg/a/base.py
from dataclasses import dataclass
@dataclass
class PricePoint:
value: float
# pkg/b/consumer.py
from pkg.a.base import PricePoint
def latest(rows: list[PricePoint]) -> PricePoint | None:
...
Run graphify's extraction over both files. Expected: one node for PricePoint (anchored at pkg/a/base.py), with an imports/uses edge from consumer.py's file node (and/or from latest()) to it.
Observed (real corpus, not the minimal repro above — I didn't isolate a standalone reproduction script, but the pattern below is exactly this shape): I got 5 separate nodes all labeled PricePoint across a 5-file corpus where one file defines it and 4 others import + reference it in type annotations:
agri_baseline_pricepoint <- correct, at the defining file
signal_intelligence_connections_prices_py_pricepoint <- ghost duplicate
signal_intelligence_fronts_agri_deviation_py_pricepoint <- ghost duplicate
signal_intelligence_fronts_upstream_watch_signals_py_pricepoint <- ghost duplicate
signal_intelligence_signal_resolution_py_pricepoint <- ghost duplicate
The 4 ghost duplicates all share one wrong-ID pattern: signal_intelligence_<full_relative_path_with_underscores>_py_pricepoint — i.e. the full path from the project root gets baked into the ID, while the correct/defining node instead uses the short {immediate_parent_dir}_{filename_stem}_{entity} scheme (agri_baseline_pricepoint). These are two different ID-generation schemes colliding.
I found 7 more instances of the same pattern in the same run, but with the other malformed variant — a bare, unprefixed lowercase name instead of a fully-qualified one:
BaselineResult -> agri_baseline_baselineresult (correct) vs baselineresult (ghost, from a different file's type annotation)
GapClass -> ledger_quality_gapclass (correct) vs gapclass (ghost, referenced from a file in a different top-level subdirectory)
DayRecord -> agri_ingest_dayrecord (correct) vs dayrecord (ghost)
DeviationRecord-> agri_deviation_deviationrecord (correct) vs deviationrecord (ghost)
GateResult -> ledger_risk_gateresult (correct) vs gateresult (ghost)
ChokepointDay -> upstream_watch_ingest_chokepointday (correct) vs chokepointday (ghost)
PotashMonth -> upstream_watch_ingest_potashmonth (correct) vs potashmonth (ghost)
All 11 ghost duplicates were verified against source: every one of these classes is defined exactly once (confirmed via grep -n "^class "), and the "extra" nodes are 100% attributable to a different file's import + type-annotation usage, never a genuine second definition.
What I ruled out
I confirmed this is not a semantic-subagent/LLM extraction issue: I re-ran the semantic (Claude subagent) extraction pass with an explicit canonical-ID glossary telling subagents exactly which ID to use for each of these 24 classes, and the corrected subagent output had zero duplicate nodes for any of them. I then diffed the subagents' new output against .graphify_ast.json (the structural, non-LLM extraction) and found all 11 duplicate IDs originate purely from the AST pass, confirming the bug is in extract_python's (or the shared cross-file resolution pass around resolve_exported_origin/facts.uses in extract.py) handling of Python — not the LLM layer.
Suspected area
graphify/extract.py:
_python_collect_type_refs (~line 477) walks a Python type annotation and appends (name, "type") — just the raw identifier text, not yet resolved to a file.
- The cross-file resolution path around
resolve_exported_origin / facts.uses / local_aliases_by_file (~line 6874 onward) looks primarily written for JS/TS ES-module semantics (named exports, star exports) and may not correctly resolve a Python from X import Y alias back to Y's defining-file node ID in all cases — falling back to a synthesized ID (either the referencing file's own full-path-qualified stem, or no prefix at all) instead of failing over to a "no edge" or a proper lookup.
I haven't isolated the exact branch producing each of the two different malformed-ID shapes (full-path vs bare), so treat the above as a strong lead rather than a confirmed line number.
Suggested fix
- When a Python type annotation reference (
_python_collect_type_refs output) can't be resolved to an existing symbol node via the file's own from X import Y aliases, either (a) resolve it properly against the corpus-wide symbol table keyed by (defining_file, class_name), or (b) don't synthesize a new node ID at all — drop the edge rather than create a duplicate node with a fabricated ID.
- Add a post-extraction consistency check: if two nodes share an identical
label and one of them has zero real definition-site metadata (no contains edge from a file node, no docstring, etc.), that's a strong signal one of them is a ghost duplicate worth auto-merging or at least flagging in GRAPH_REPORT.md's "Knowledge Gaps" section.
Environment
graphifyy==0.8.28 (via uv tool install graphifyy)
- macOS (Darwin 25.6.0, arm64), Python 3.14 (the tool's own venv)
- Corpus: 20 Python files + 1 markdown doc, real project code (not synthetic), all files under one repo root
Happy to share the full graphify-out/graph.json (before my manual post-processing merge) if useful — didn't attach it here since it's from a private-ish personal project, but I can redact/trim it to just the relevant nodes/edges if that helps debug.
Summary
Running the graphify skill's Python AST extraction (
graphify.extract.extract_python) on a corpus where a dataclass/class is defined in one file and referenced (imported + used in a type annotation, e.g. a function return type or another dataclass's field type) from a different file produces two separate nodes with the same label — one at the defining file's correct node ID, and one "ghost" duplicate anchored at the referencing file, instead of a single node with animports/referencesedge pointing at it.This inflates degree/centrality metrics (a class can show up as a "god node" partly due to its own duplicate's edges being counted separately) and fragments community detection (each ghost duplicate can end up isolated in its own thin community).
Reproduction
A minimal repro, mirroring what I hit:
Run graphify's extraction over both files. Expected: one node for
PricePoint(anchored atpkg/a/base.py), with animports/usesedge fromconsumer.py's file node (and/or fromlatest()) to it.Observed (real corpus, not the minimal repro above — I didn't isolate a standalone reproduction script, but the pattern below is exactly this shape): I got 5 separate nodes all labeled
PricePointacross a 5-file corpus where one file defines it and 4 others import + reference it in type annotations:The 4 ghost duplicates all share one wrong-ID pattern:
signal_intelligence_<full_relative_path_with_underscores>_py_pricepoint— i.e. the full path from the project root gets baked into the ID, while the correct/defining node instead uses the short{immediate_parent_dir}_{filename_stem}_{entity}scheme (agri_baseline_pricepoint). These are two different ID-generation schemes colliding.I found 7 more instances of the same pattern in the same run, but with the other malformed variant — a bare, unprefixed lowercase name instead of a fully-qualified one:
All 11 ghost duplicates were verified against source: every one of these classes is defined exactly once (confirmed via
grep -n "^class "), and the "extra" nodes are 100% attributable to a different file'simport+ type-annotation usage, never a genuine second definition.What I ruled out
I confirmed this is not a semantic-subagent/LLM extraction issue: I re-ran the semantic (Claude subagent) extraction pass with an explicit canonical-ID glossary telling subagents exactly which ID to use for each of these 24 classes, and the corrected subagent output had zero duplicate nodes for any of them. I then diffed the subagents' new output against
.graphify_ast.json(the structural, non-LLM extraction) and found all 11 duplicate IDs originate purely from the AST pass, confirming the bug is inextract_python's (or the shared cross-file resolution pass aroundresolve_exported_origin/facts.usesinextract.py) handling of Python — not the LLM layer.Suspected area
graphify/extract.py:_python_collect_type_refs(~line 477) walks a Python type annotation and appends(name, "type")— just the raw identifier text, not yet resolved to a file.resolve_exported_origin/facts.uses/local_aliases_by_file(~line 6874 onward) looks primarily written for JS/TS ES-module semantics (named exports, star exports) and may not correctly resolve a Pythonfrom X import Yalias back toY's defining-file node ID in all cases — falling back to a synthesized ID (either the referencing file's own full-path-qualified stem, or no prefix at all) instead of failing over to a "no edge" or a proper lookup.I haven't isolated the exact branch producing each of the two different malformed-ID shapes (full-path vs bare), so treat the above as a strong lead rather than a confirmed line number.
Suggested fix
_python_collect_type_refsoutput) can't be resolved to an existing symbol node via the file's ownfrom X import Yaliases, either (a) resolve it properly against the corpus-wide symbol table keyed by(defining_file, class_name), or (b) don't synthesize a new node ID at all — drop the edge rather than create a duplicate node with a fabricated ID.labeland one of them has zero real definition-site metadata (nocontainsedge from a file node, no docstring, etc.), that's a strong signal one of them is a ghost duplicate worth auto-merging or at least flagging inGRAPH_REPORT.md's "Knowledge Gaps" section.Environment
graphifyy==0.8.28(viauv tool install graphifyy)Happy to share the full
graphify-out/graph.json(before my manual post-processing merge) if useful — didn't attach it here since it's from a private-ish personal project, but I can redact/trim it to just the relevant nodes/edges if that helps debug.