Python: methods differing only by a leading underscore collide on node id — the public one is silently dropped from the graph · Issue #3302 · Graphify-Labs/graphify · GitHub
You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Python: methods differing only by a leading underscore collide on node id — the public one is silently dropped from the graph #3302
Version: 0.9.53 (latest on PyPI at time of writing; also reproduced on 0.9.47) Platform: Windows 11, Python 3.14.7, isolated venv (pip install graphifyy==0.9.53) Command:graphify extract . --code-only --no-cluster
Note for maintainers: I first sent this through private vulnerability reporting
(GHSA-7hhr-924m-gwrf) because SECURITY.md asks that security-relevant reports not be
opened publicly. On reflection that was the wrong channel � this is a correctness bug, not
a vulnerability in graphify � so I'm refiling it here. Please close that private report as
a duplicate of this issue; I can't close it myself as the reporter.
Summary
When a class defines two methods whose names differ only by a leading underscore (_foo and foo), only one node reaches graph.json. Node ids appear to be slugified in a way that
strips the leading underscore, so both become <module>_<class>_foo, and the second
definition silently overwrites the first.
No warning is emitted, exit status is 0, and the file is not reported as partially extracted.
A public API method can therefore be entirely absent from the graph while its private
sibling occupies the public name.
Expected: three method nodes. Actual: two. Adapter.get_connection (L5) is absent;
the id that would name it is held by _get_connection (L2).
The unrelated control extracts correctly, so this is specific to the underscore-differing
pair, not to methods generally.
Real-world impact
Found while benchmarking context retrieval against psf/requests at aa1461b6^. src/requests/adapters.py defines both HTTPAdapter._get_connection (L377) and HTTPAdapter.get_connection (L406). The graph contains a single node src_requests_adapters_httpadapter_get_connection pointing at L377; the public get_connection at L406 has no node at all, only a stray docstring node at L407.
In a 25-symbol ground-truth set built from that repository's own fix commits, this defect
accounted for a measurable share of symbols that no graph query (affected, explain, query, path) could reach. Anyone relying on graph completeness � for an API audit, a
refactor impact review, or a security pass � gets a quiet false negative.
Root cause (hypothesis) and suggested fix
Id generation appears to normalise identifiers by stripping non-alphanumeric characters,
including the leading _, without disambiguating collisions.
Preserve the underscore in the id (e.g. mod_adapter__get_connection); or
Detect an id collision at insert time and disambiguate rather than overwrite.
(2) is more robust, since the same collapse likely affects other character-stripping cases
(foo_bar vs fooBar vs foo-bar, dunder names). A regression test asserting node count == definition count for a file would catch the whole class.
Follow-up: measuring this across a whole repository shows the defect is broader than the leading-underscore case in the original report. The underscore pair is one instance of a general id-collision problem.
Method: parsed every .py file in 14 checkouts of psf/requests with tree-sitter, and checked whether each definition has a graph node at its own definition line.
12,379 definitions, 711 with no node at their definition line (5.7%).
Excluding nested functions (a separate cause, 50% of misses) and dunders (20%), 46% of the remainder (97 of 211) share a slugged node id with a different symbol that is present.
The collisions are not only underscore-related:
missing symbol
id taken by
collapse
session() @ L705 requests/sessions.py
Session @ L270 (class)
case
_SixMetaPathImporter.__get_module @ L189
_SixMetaPathImporter._get_module @ L181
leading underscore
get_unbound_function @ L546 six.py
same name @ L536
conditional re-definition
The Session / session() pair is probably the clearest case to fix against: a public class and a public module-level factory function, in the same module, and the function is silently absent from the graph.
So the underlying issue looks like id slugification lowercases and strips non-alphanumerics, then later definitions overwrite earlier ones on collision, rather than anything specific to underscores. That makes the suggested fix in the original report (detect collisions at insert time and disambiguate, plus a regression test asserting node count == definition count per file) the one that covers the whole class; preserving underscores alone would fix only the narrow case.
Caveat on the numbers: much of the sampled loss sits in vendored compat code (requests/packages/urllib3/packages/six.py), which is unusually dense in conditionally re-defined names, so the 46% figure should be read as indicative of the pattern rather than as a repo-independent rate.
Correction to my previous comment — the collision share is higher than I stated.
I reported that id collisions accounted for 46% of non-nested, non-dunder misses. That figure was wrong, and the reason is worth being explicit about: my classification script tested "is this a dunder" before "is this nested inside a function", so every dunder defined inside a closure was counted as a dunder miss rather than a nesting miss. That both inflated the dunder bucket and, by leaving mis-sorted symbols in the denominator, diluted the collision share.
Re-run with function-nesting taking precedence (same 14 checkouts of psf/requests, 12,379 definitions found by tree-sitter):
cause
all files
first-party only (vendored requests/packages/ excluded)
nested inside a function
610 (86% of misses)
589 (93%)
plain / leading-underscore
82 (12%)
33 (5%)
decorated
15 (2%)
10 (2%)
dunder, not nested
4 (1%)
4 (1%)
total missing
711 of 12,379 (5.7%)
636 of 8,231 (7.7%)
Two things follow for this issue:
Nested functions are a separate matter and I'm not raising them here. 0 of 610 have a node — a total, consistent absence, which reads as a deliberate scope choice (closures/inner helpers aren't modelled) rather than a defect.
Of the remaining non-nested, non-dunder misses, id collisions account for 100% — 97 of 97 across all files, 43 of 43 in first-party code. Every one of them is a definition whose slugged id is held by a different symbol that is present. So the collision described in this issue isn't a share of the extraction gap; outside the by-design nested case, it is the extraction gap.
Also correcting an assumption in my earlier comment: excluding vendored code makes the miss rate worse (7.7% vs 5.7%), not better — the vendored compat code was diluting the rate rather than inflating it.
The suggested fix is unchanged: detect the collision at insert time and disambiguate rather than overwrite, with a regression test asserting node count equals definition count per file for non-nested definitions.
Activity
Turbo-Soggy commentedon Sep 3, 2026
Follow-up: measuring this across a whole repository shows the defect is broader than the leading-underscore case in the original report. The underscore pair is one instance of a general id-collision problem.
Method: parsed every
.pyfile in 14 checkouts ofpsf/requestswith tree-sitter, and checked whether each definition has a graph node at its own definition line.The collisions are not only underscore-related:
session()@ L705requests/sessions.pySession@ L270 (class)_SixMetaPathImporter.__get_module@ L189_SixMetaPathImporter._get_module@ L181get_unbound_function@ L546six.pyThe
Session/session()pair is probably the clearest case to fix against: a public class and a public module-level factory function, in the same module, and the function is silently absent from the graph.So the underlying issue looks like id slugification lowercases and strips non-alphanumerics, then later definitions overwrite earlier ones on collision, rather than anything specific to underscores. That makes the suggested fix in the original report (detect collisions at insert time and disambiguate, plus a regression test asserting node count == definition count per file) the one that covers the whole class; preserving underscores alone would fix only the narrow case.
Caveat on the numbers: much of the sampled loss sits in vendored compat code (
requests/packages/urllib3/packages/six.py), which is unusually dense in conditionally re-defined names, so the 46% figure should be read as indicative of the pattern rather than as a repo-independent rate.Turbo-Soggy commentedon Sep 3, 2026
Correction to my previous comment — the collision share is higher than I stated.
I reported that id collisions accounted for 46% of non-nested, non-dunder misses. That figure was wrong, and the reason is worth being explicit about: my classification script tested "is this a dunder" before "is this nested inside a function", so every dunder defined inside a closure was counted as a dunder miss rather than a nesting miss. That both inflated the dunder bucket and, by leaving mis-sorted symbols in the denominator, diluted the collision share.
Re-run with function-nesting taking precedence (same 14 checkouts of
psf/requests, 12,379 definitions found by tree-sitter):requests/packages/excluded)Two things follow for this issue:
Nested functions are a separate matter and I'm not raising them here. 0 of 610 have a node — a total, consistent absence, which reads as a deliberate scope choice (closures/inner helpers aren't modelled) rather than a defect.
Of the remaining non-nested, non-dunder misses, id collisions account for 100% — 97 of 97 across all files, 43 of 43 in first-party code. Every one of them is a definition whose slugged id is held by a different symbol that is present. So the collision described in this issue isn't a share of the extraction gap; outside the by-design nested case, it is the extraction gap.
Also correcting an assumption in my earlier comment: excluding vendored code makes the miss rate worse (7.7% vs 5.7%), not better — the vendored compat code was diluting the rate rather than inflating it.
The suggested fix is unchanged: detect the collision at insert time and disambiguate rather than overwrite, with a regression test asserting node count equals definition count per file for non-nested definitions.
Fix salt-truncation fallback to collapse onto the plain id
Fix Python leading-underscore method/function id collision
fix(csharp): keep names that differ only by `_` or case apart, in ids…
fix(python): salt module-level class ids that collide with a function…
fix(go,rust): keep same-file declarations whose names differ only by …