Skip to content

Node IDs collide for files sharing a basename (e.g. layout.py vs layout.ts) #1829

Description

@shuff57

Summary

graphify.extract derives every node ID from path.stem (the extension-less basename). Two source files that share a basename therefore collapse into a single graph node — regardless of language or directory. This silently cross-wires unrelated code: edges from one file attach to another file's symbols.

Environment

  • graphify (PyPI graphifyy), current release as of 2026-07
  • Python 3.14, Windows; the bug is language-agnostic, not OS-specific

Reproduce

Two files sharing the basename layout:

  • a/layout.py — a Python class Layout
  • b/layout.ts — an unrelated TypeScript module
from graphify.extract import extract
from pathlib import Path
r = extract([Path("a/layout.py"), Path("b/layout.ts")])
ids = [n["id"] for n in r["nodes"]]
print("collisions:", len(ids) - len(set(ids)))   # > 0

Observed: both files map to the file node ID layout. The TS file's contains edges point at the Python Layout class/methods, and imports_from edges make the TS file appear to import Python modules.

Expected: each file gets a distinct node ID; edges stay within their own file.

Root cause

extract.py:

def _make_id(*parts: str) -> str:
    combined = "_".join(p.strip("_.") for p in parts if p)
    cleaned = re.sub(r"[^a-zA-Z0-9]+", "_", combined)
    return cleaned.strip("_").lower()

# ...in each per-file extractor:
stem = path.stem            # no extension, no path -> collides
file_nid = _make_id(stem)

_make_id has no path/extension awareness, so stem is the only disambiguator and it isn't unique across files.

Suggested fix

Add a path-aware stem and use it wherever a file-scoped ID is built:

def _stem_id(path: Path) -> str:
    import hashlib
    rel = str(path).replace("\\", "/")
    h = hashlib.sha1(rel.encode("utf-8")).hexdigest()[:6]
    suffix = path.suffix.lstrip(".") or "noext"
    return f"{path.stem}_{suffix}_{h}"   # layout.py -> layout_py_acdcd1

Replace stem = path.stem -> stem = _stem_id(path) in every per-file extractor (and the Go extractor), and update the Python import-resolver's file-node exclusion check to _make_id(_stem_id(path)).

Do NOT change the import-resolver's module-name keys (Path(src).stem, {p.stem: p for p in paths}) — those must stay extensionless because they match Python import module names (from layout import X), which have no extension.

Impact / migration note

The fix changes every code node's ID, so existing persisted graphs won't merge on the next incremental --update; a full rebuild is needed once. Worth calling out in the release notes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions