Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
843 changes: 672 additions & 171 deletions graphify/extract.py

Large diffs are not rendered by default.

9 changes: 9 additions & 0 deletions tests/fixtures/collision/app1/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace App1;

public class Program
{
public static void Main()
{
var svc = new MyService();
}
}
9 changes: 9 additions & 0 deletions tests/fixtures/collision/app2/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace App2;

public class Program
{
public static void Run()
{
var helper = new Helper();
}
}
2 changes: 2 additions & 0 deletions tests/fixtures/collision/preexist/proj1/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
namespace Proj1;
public class Startup { public void Run() { } }
4 changes: 4 additions & 0 deletions tests/fixtures/collision/preexist/proj1_startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
namespace Preexist;
// This file's stem is "proj1_startup", so _make_id("proj1_startup", "Preexist")
// produces an ID that could collide with the renamed "startup" from proj1/Startup.cs
public class Preexist { public void Work() { } }
2 changes: 2 additions & 0 deletions tests/fixtures/collision/preexist/proj2/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
namespace Proj2;
public class Startup { public void Init() { } }
6 changes: 6 additions & 0 deletions tests/fixtures/collision/src/app/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace SrcApp;

public class Startup
{
public void Configure() { }
}
6 changes: 6 additions & 0 deletions tests/fixtures/collision/tests/app/Startup.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace TestApp;

public class Startup
{
public void ConfigureTest() { }
}
83 changes: 83 additions & 0 deletions tests/test_node_id_collision.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Tests for node ID collision disambiguation in extract()."""

from pathlib import Path

from graphify.extract import extract, collect_files

COLLISION_DIR = Path(__file__).parent / "fixtures" / "collision"


def _extract_collision_files(subdirs: list[str]) -> dict:
"""Run extract() on files from the given subdirectories."""
files = []
for subdir in subdirs:
d = COLLISION_DIR / subdir
files.extend(collect_files(d))
return extract(files)


def test_same_stem_different_dirs_get_unique_ids():
"""Two Program.cs in different dirs must produce distinct node IDs."""
result = _extract_collision_files(["app1", "app2"])
ids = [n["id"] for n in result["nodes"]]
program_ids = [i for i in ids if "program" in i and "program_cs" not in i.replace("program_cs", "")]
# There must be at least two distinct Program-class nodes
class_ids = [i for i in ids if i.endswith("_program") or "_program_" in i]
assert len(set(class_ids)) >= 2, f"Expected distinct IDs, got: {class_ids}"


def test_no_node_ids_collide_after_disambiguation():
"""After disambiguation, all node IDs must be unique."""
result = _extract_collision_files(["app1", "app2"])
ids = [n["id"] for n in result["nodes"]]
assert len(ids) == len(set(ids)), f"Duplicate IDs found: {[i for i in ids if ids.count(i) > 1]}"


def test_edges_reference_valid_nodes():
"""All edge sources and targets must exist in the node set."""
result = _extract_collision_files(["app1", "app2"])
node_ids = {n["id"] for n in result["nodes"]}
for e in result["edges"]:
assert e["source"] in node_ids, f"Dangling source: {e['source']}"
# targets may reference external (unresolved) nodes, but sources must exist


def test_same_parent_dir_uses_deeper_path():
"""src/app/Startup.cs and tests/app/Startup.cs share parent 'app',
so disambiguation must use deeper path components."""
result = _extract_collision_files(["src/app", "tests/app"])
ids = [n["id"] for n in result["nodes"]]
startup_ids = [i for i in ids if "startup" in i and not i.endswith("_cs")]
assert len(set(startup_ids)) >= 2, (
f"Expected distinct IDs even with same parent dir, got: {startup_ids}"
)


def test_cross_file_edges_remapped():
"""Edges crossing file boundaries must have their targets remapped."""
result = _extract_collision_files(["app1", "app2"])
node_ids = {n["id"] for n in result["nodes"]}
for e in result["edges"]:
if e["source"] in node_ids:
# If we can verify the target, it must not be an old pre-rename ID
if e["target"] in node_ids:
continue
# Target may be an external ref (MyService, Helper) — that's fine


def test_rename_does_not_collide_with_preexisting_id():
"""Regression: a renamed ID must not collide with an existing node ID
from a non-colliding group. proj1/Startup.cs and proj2/Startup.cs collide
on 'startup_startup'; the rename of proj1's node must not produce an ID
that clashes with the node from proj1_startup.cs."""
result = _extract_collision_files(["preexist/proj1", "preexist/proj2", "preexist"])
ids = [n["id"] for n in result["nodes"]]
assert len(ids) == len(set(ids)), (
f"Duplicate IDs after disambiguation (preexisting collision): "
f"{[i for i in ids if ids.count(i) > 1]}"
)
# All file nodes and class nodes must survive — none dropped
node_files = {n.get("source_file", "") for n in result["nodes"]}
assert any("proj1/Startup.cs" in f or "proj1\\Startup.cs" in f for f in node_files)
assert any("proj2/Startup.cs" in f or "proj2\\Startup.cs" in f for f in node_files)
assert any("proj1_startup.cs" in f for f in node_files)