Skip to content

Case-folded cross-file resolution collides distinct identifiers (Path/PATH) → false super-hubs in case-sensitive languages #1581

Closed

Description

@sheik-hiiobd

Summary

Cross-file name resolution matches identifiers case-insensitively for every language. In case-sensitive languages that's wrong: pathlib.Path (ubiquitous) resolves to an unrelated node labeled PATH, turning a single shell-script variable into the #1 god-node of a real repo. It inflates god-node rankings, affected blast-radius, and community assignment.

Repro (2 files, AST-only, no key)

# run.sh
export PATH=/usr/local/bin:$PATH
# mod.py
from pathlib import Path
def load(p: Path) -> Path:
    return Path(p)
def other():
    return load(Path("x"))
$ graphify <dir> --no-cluster

Edges produced:

load()  -> PATH  [references]   # the `p: Path` / `-> Path` annotations
load()  -> PATH  [calls]        # Path(p)
other() -> PATH  [calls]        # Path("x")

Path (a stdlib class, not in the corpus) resolves to PATH — the shell variable in run.sh. There is no Path node, so this is resolution, not a dedup-merge.

Evidence at scale

On a real ~3,700-node Python codebase:

  • one node labeled PATH, sourced from a shell script's export PATH=...
  • 266 incoming edges (224 references + 41 calls + 1 defines) → the v3: semantic query with embeddings #1 god-node
  • zero files define a PATH constant; every incoming edge is a file doing from pathlib import Path
  • graphify affected PATH returns 266 false positives; the true blast radius of the shell var is ~0

Root cause

Identifier matching case-folds. The cross-file resolver keys its candidate index and its lookups on .lower():

  • graphify/extract.py:15282 — key = normalised.lower() (global index build)
  • graphify/extract.py:15337 — candidates = global_label_to_nids.get(callee.lower(), [])

and the dedup key case-folds too:

  • graphify/build.py:633 — _norm_label(...) → label.casefold()

So Path, PATH, and path collapse to one key. A single case-folded candidate resolves directly (extract.py:15356), even with no import evidence.

The principle

Case is semantic in most languages: Path (class), PATH (constant/env), path (variable) are distinct in Python, JS, Go, Rust, Java, C#, Kotlin, Swift, Ruby, C/C++. Case-insensitive matching is correct only where identifiers are case-insensitive — PHP (functions/classes), SQL, Nim (style-insensitive).

The fix is a language-gated policy: case-fold matching only when the identifier's language is case-insensitive; otherwise require an exact-case match. For case-sensitive languages this only ever removes false edges — a case-mismatched cross-file match is always a false positive.

Sites that need the same gate (and ones to leave alone)

  • global cross-file call resolver (extract.py:15275-15357)
  • type-reference (references) resolution
  • the _norm_label dedup key (build.py:633) used by deduplicate_entities
  • leave alone — legitimately case-insensitive: the Nim resolver (extract.py:14136) and the PHP label_to_nid_ci index (extract.py:4304)

Suggested test (negative-first)

  • case-sensitive language: Path (class ref) must not resolve to a PATH node; Path/PATH/path stay three distinct nodes
  • case-insensitive language stays intact: PHP Foo() defined, foo() called → edge still resolves

Happy to send a PR — wanted your read on the case-sensitivity policy (the exact case-insensitive-language set) first, since the gate touches the shared resolver and I'd rather not disturb the intended PHP/SQL/Nim behavior.

Activity

  1. safishamsi commented on Jul 1, 2026

    @safishamsi
    Member

    Confirmed and fixed on v8 (784e9c8) — thanks, the diagnosis was exact. Reproduced it: a Python from pathlib import Path resolved every reference/call onto a shell script's export PATH=... node, and affected PATH returned the false blast radius you described.

    On the policy you asked about — I went with your language-gated approach:

    • Case-insensitive set: PHP (.php + variants), SQL (.sql), Nim (.nim/.nims/.nimble). Everything else matches by exact case.
    • Sites gated (both the repro exercised):
      • the global cross-file call resolver — indexed by exact case, with a folded index built only for case-insensitive-language nodes and consulted only when the calling file is one of those languages;
      • the type-reference stub rewire (_rewire_unique_stub_nodes) — exact-case match, with a folded fallback restricted to case-insensitive-language definitions, so a case-sensitive PATH can never absorb a Path stub.
    • Left folding (correctly): the PHP label_to_nid_ci path, the Nim resolver, and the concept/doc dedup in dedup.py — that one is already guarded to non-code nodes (_is_code), so it isn't collapsing code symbols; case-insensitive concept merging is intended there.

    Verified: Python Path no longer touches shell PATH (PATH drops from a 266-edge super-hub to just its own defines); a case-differing cross-file ref doesn't resolve; exact-case cross-file resolution still works; PHP run() -> greet()/Greet() still folds. Full suite green (2777).

    Kept the issue open for your eyes on the case-insensitive language set — if SQL/Nim shouldn't be in there (or something like PowerShell should), it's a one-line change to _CASE_INSENSITIVE_EXTS. Ships next release.

  2. added a commit that references this issue on Jul 2, 2026
  3. safishamsi commented on Jul 2, 2026

    @safishamsi
    Member

    Shipped in 0.9.5 (pip install -U graphifyy==0.9.5). Closing.

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