Repository navigation
Case-folded cross-file resolution collides distinct identifiers (Path/PATH) → false super-hubs in case-sensitive languages #1581
Description
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'sexport PATH=... - 266 incoming edges (224
references+ 41calls+ 1defines) → the v3: semantic query with embeddings #1 god-node - zero files define a
PATHconstant; every incoming edge is a file doingfrom pathlib import Path graphify affected PATHreturns 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_labeldedup key (build.py:633) used bydeduplicate_entities - leave alone — legitimately case-insensitive: the Nim resolver (
extract.py:14136) and the PHPlabel_to_nid_ciindex (extract.py:4304)
Suggested test (negative-first)
- case-sensitive language:
Path(class ref) must not resolve to aPATHnode;Path/PATH/pathstay 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
fix(extract): case-sensitive cross-file resolution in case-sensitive …
safishamsi commentedon Jul 1, 2026
Confirmed and fixed on
v8(784e9c8) — thanks, the diagnosis was exact. Reproduced it: a Pythonfrom pathlib import Pathresolved every reference/call onto a shell script'sexport PATH=...node, andaffected PATHreturned the false blast radius you described.On the policy you asked about — I went with your language-gated approach:
.php+ variants), SQL (.sql), Nim (.nim/.nims/.nimble). Everything else matches by exact case._rewire_unique_stub_nodes) — exact-case match, with a folded fallback restricted to case-insensitive-language definitions, so a case-sensitivePATHcan never absorb aPathstub.label_to_nid_cipath, the Nim resolver, and the concept/doc dedup indedup.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
Pathno longer touches shellPATH(PATH drops from a 266-edge super-hub to just its owndefines); a case-differing cross-file ref doesn't resolve; exact-case cross-file resolution still works; PHPrun() -> 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.release: 0.9.5
safishamsi commentedon Jul 2, 2026
Shipped in 0.9.5 (
pip install -U graphifyy==0.9.5). Closing.fix(cache): fall back to git commit when package metadata is unavailable
fix(extract): case-sensitive cross-file resolution in case-sensitive …
release: 0.9.5