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
Skip to content

Python: methods differing only by a leading underscore collide on node id — the public one is silently dropped from the graph #3302

Closed

Description

@Turbo-Soggy

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.

Reproduction

mod.py � three methods in, two nodes out:

class Adapter:
    def _get_connection(self, url):     # L2
        return url

    def get_connection(self, url):      # L5
        return self._get_connection(url)

    def unrelated(self, x):             # L8   <- control
        return x
$ graphify extract . --code-only --no-cluster
[graphify extract] wrote graphify-out/graph.json - 4 nodes, 3 edges (no clustering)

Nodes for mod.py:

mod                         | mod.py             | L1
mod_adapter                 | Adapter            | L1
mod_adapter_get_connection  | ._get_connection() | L2
mod_adapter_unrelated       | .unrelated()       | L8

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.

  1. Preserve the underscore in the id (e.g. mod_adapter__get_connection); or
  2. 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.

Related, but not duplicates

Activity

  1. Turbo-Soggy commented on Sep 3, 2026

    @Turbo-Soggy
    Author

    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.

  2. Turbo-Soggy commented on Sep 3, 2026

    @Turbo-Soggy
    Author

    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:

    1. 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.

    2. 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.

  3. added a commit that references this issue on Sep 5, 2026
    343f76f
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