From abbd5c2bfd6abe0b50c1dec5d639eb2ea3b1ccb7 Mon Sep 17 00:00:00 2001 From: Vladimir Rogozhin Date: Sat, 18 Apr 2026 13:51:11 +0200 Subject: [PATCH 1/3] fix: disambiguate colliding node IDs from same-name files When two files share the same stem (e.g. Program.cs in BKD.Api and BooksKnowledgeDistillation), _make_id(stem, name) produces identical node IDs, causing nodes to merge incorrectly. Detect ID collisions across different source files and disambiguate by prepending the parent directory name to the ID. This ensures each Program class gets its own node in the graph. Made-with: Cursor --- graphify/extract.py | 58 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/graphify/extract.py b/graphify/extract.py index dbd441c6c..98c856781 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3341,6 +3341,64 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: except Exception as exc: import logging logging.getLogger(__name__).warning("Java cross-file import resolution failed, skipping: %s", exc) + # ── Disambiguate colliding node IDs across files ───────────────────────── + # _make_id(stem, name) collides when two files have the same stem (e.g. + # Program.cs in different projects). Detect and rename with parent dir. + id_to_files: dict[str, list[dict]] = {} + for n in all_nodes: + nid = n["id"] + id_to_files.setdefault(nid, []).append(n) + + rename_map: dict[str, str] = {} + dedup_nodes: list[dict] = [] + seen_final_ids: set[str] = set() + for nid, node_list in id_to_files.items(): + real_nodes = [n for n in node_list if n.get("source_file")] + unique_files = {n["source_file"] for n in real_nodes} + if len(unique_files) > 1: + for n in node_list: + sf = n.get("source_file", "") + if sf: + parent = Path(sf).parent.name + new_id = _make_id(parent, nid) + if new_id not in seen_final_ids: + n["id"] = new_id + rename_map[nid + "|" + sf] = new_id + seen_final_ids.add(new_id) + dedup_nodes.append(n) + else: + if nid not in seen_final_ids: + seen_final_ids.add(nid) + dedup_nodes.append(n) + else: + for n in node_list: + if nid not in seen_final_ids: + seen_final_ids.add(nid) + dedup_nodes.append(n) + + if rename_map: + file_to_renames: dict[str, dict[str, str]] = {} + for key, new_id in rename_map.items(): + old_id, sf = key.rsplit("|", 1) + file_to_renames.setdefault(sf, {})[old_id] = new_id + + all_nodes = dedup_nodes + for e in all_edges: + sf = e.get("source_file", "") + renames = file_to_renames.get(sf, {}) + if e["source"] in renames: + e["source"] = renames[e["source"]] + if e["target"] in renames: + e["target"] = renames[e["target"]] + else: + first_seen: set[str] = set() + unique_nodes: list[dict] = [] + for n in all_nodes: + if n["id"] not in first_seen: + first_seen.add(n["id"]) + unique_nodes.append(n) + all_nodes = unique_nodes + # Cross-file call resolution for all languages # Each extractor saved unresolved calls in raw_calls. Now that we have all From 4eecadebcda511c46ba020575ff3b987b2f9da72 Mon Sep 17 00:00:00 2001 From: Vladimir Rogozhin Date: Mon, 27 Apr 2026 09:44:22 +0200 Subject: [PATCH 2/3] fix: address review feedback on node ID collision disambiguation Fixes three bugs found during review: 1. Cross-file edge targets could remain stale after renaming: edge remap was scoped by source_file, so a target in another file would not be updated. Now uses a global rename map that checks all files for the old target ID. 2. raw_calls caller_nid was not remapped after disambiguation, causing inferred cross-file edges to reference non-existent source IDs. Now remaps caller_nid before cross-file resolution. 3. Replacement IDs could still collide when two files share the same parent directory name (e.g. src/app/Program.cs and tests/app/Program.cs). Now uses progressively deeper path components until a unique ID is found. Adds 5 unit tests covering: - Same-stem files get unique IDs - No duplicate IDs after disambiguation - All edge sources reference valid nodes - Same parent dir uses deeper path components - Cross-file edges are properly remapped Made-with: Cursor --- graphify/extract.py | 60 ++++++++++++----- tests/fixtures/collision/app1/Program.cs | 9 +++ tests/fixtures/collision/app2/Program.cs | 9 +++ tests/fixtures/collision/src/app/Startup.cs | 6 ++ tests/fixtures/collision/tests/app/Startup.cs | 6 ++ tests/test_node_id_collision.py | 65 +++++++++++++++++++ 6 files changed, 140 insertions(+), 15 deletions(-) create mode 100644 tests/fixtures/collision/app1/Program.cs create mode 100644 tests/fixtures/collision/app2/Program.cs create mode 100644 tests/fixtures/collision/src/app/Startup.cs create mode 100644 tests/fixtures/collision/tests/app/Startup.cs create mode 100644 tests/test_node_id_collision.py diff --git a/graphify/extract.py b/graphify/extract.py index 98c856781..290e5cd5b 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -3343,13 +3343,15 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: logging.getLogger(__name__).warning("Java cross-file import resolution failed, skipping: %s", exc) # ── Disambiguate colliding node IDs across files ───────────────────────── # _make_id(stem, name) collides when two files have the same stem (e.g. - # Program.cs in different projects). Detect and rename with parent dir. + # Program.cs in different projects). Detect and rename with parent dir, + # using progressively more path components if the parent dir also collides. id_to_files: dict[str, list[dict]] = {} for n in all_nodes: - nid = n["id"] - id_to_files.setdefault(nid, []).append(n) + id_to_files.setdefault(n["id"], []).append(n) - rename_map: dict[str, str] = {} + # Global old→new rename map (not scoped by file) so cross-file edges + # and raw_calls are remapped correctly. + global_rename: dict[str, str] = {} dedup_nodes: list[dict] = [] seen_final_ids: set[str] = set() for nid, node_list in id_to_files.items(): @@ -3359,13 +3361,20 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: for n in node_list: sf = n.get("source_file", "") if sf: - parent = Path(sf).parent.name - new_id = _make_id(parent, nid) - if new_id not in seen_final_ids: - n["id"] = new_id - rename_map[nid + "|" + sf] = new_id - seen_final_ids.add(new_id) - dedup_nodes.append(n) + parts = Path(sf).parts + new_id = None + for depth in range(1, len(parts)): + prefix = "_".join(parts[-(depth + 1):-1]) + candidate = _make_id(prefix, nid) + if candidate not in seen_final_ids: + new_id = candidate + break + if new_id is None: + new_id = _make_id("_".join(parts), nid) + n["id"] = new_id + global_rename[nid + "|" + sf] = new_id + seen_final_ids.add(new_id) + dedup_nodes.append(n) else: if nid not in seen_final_ids: seen_final_ids.add(nid) @@ -3376,20 +3385,41 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: seen_final_ids.add(nid) dedup_nodes.append(n) - if rename_map: + if global_rename: + # Build per-file rename lookup for source/target remapping file_to_renames: dict[str, dict[str, str]] = {} - for key, new_id in rename_map.items(): + for key, new_id in global_rename.items(): old_id, sf = key.rsplit("|", 1) file_to_renames.setdefault(sf, {})[old_id] = new_id + # Also build a flat old_id→new_id map per file for global edge remap + all_renames_by_old: dict[str, dict[str, str]] = {} + for key, new_id in global_rename.items(): + old_id, sf = key.rsplit("|", 1) + all_renames_by_old.setdefault(old_id, {})[sf] = new_id all_nodes = dedup_nodes for e in all_edges: sf = e.get("source_file", "") + # Remap source: use the edge's own source_file renames = file_to_renames.get(sf, {}) if e["source"] in renames: e["source"] = renames[e["source"]] - if e["target"] in renames: - e["target"] = renames[e["target"]] + # Remap target: could be in ANY file, so check all files for this old ID + old_tgt = e["target"] + if old_tgt in all_renames_by_old: + candidates = all_renames_by_old[old_tgt] + if len(candidates) == 1: + e["target"] = next(iter(candidates.values())) + elif sf in candidates: + e["target"] = candidates[sf] + + # Remap raw_calls caller_nid so cross-file resolution uses correct IDs + for result in per_file: + for rc in result.get("raw_calls", []): + rc_sf = rc.get("source_file", "") + renames = file_to_renames.get(rc_sf, {}) + if rc["caller_nid"] in renames: + rc["caller_nid"] = renames[rc["caller_nid"]] else: first_seen: set[str] = set() unique_nodes: list[dict] = [] diff --git a/tests/fixtures/collision/app1/Program.cs b/tests/fixtures/collision/app1/Program.cs new file mode 100644 index 000000000..3731ba62d --- /dev/null +++ b/tests/fixtures/collision/app1/Program.cs @@ -0,0 +1,9 @@ +namespace App1; + +public class Program +{ + public static void Main() + { + var svc = new MyService(); + } +} diff --git a/tests/fixtures/collision/app2/Program.cs b/tests/fixtures/collision/app2/Program.cs new file mode 100644 index 000000000..a5fbbd27c --- /dev/null +++ b/tests/fixtures/collision/app2/Program.cs @@ -0,0 +1,9 @@ +namespace App2; + +public class Program +{ + public static void Run() + { + var helper = new Helper(); + } +} diff --git a/tests/fixtures/collision/src/app/Startup.cs b/tests/fixtures/collision/src/app/Startup.cs new file mode 100644 index 000000000..12a154047 --- /dev/null +++ b/tests/fixtures/collision/src/app/Startup.cs @@ -0,0 +1,6 @@ +namespace SrcApp; + +public class Startup +{ + public void Configure() { } +} diff --git a/tests/fixtures/collision/tests/app/Startup.cs b/tests/fixtures/collision/tests/app/Startup.cs new file mode 100644 index 000000000..2e59f4139 --- /dev/null +++ b/tests/fixtures/collision/tests/app/Startup.cs @@ -0,0 +1,6 @@ +namespace TestApp; + +public class Startup +{ + public void ConfigureTest() { } +} diff --git a/tests/test_node_id_collision.py b/tests/test_node_id_collision.py new file mode 100644 index 000000000..65192b7cf --- /dev/null +++ b/tests/test_node_id_collision.py @@ -0,0 +1,65 @@ +"""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 From 72e24a5306bebbe7b5c540d84eda7f11bd1c006c Mon Sep 17 00:00:00 2001 From: Vladimir Rogozhin Date: Sat, 9 May 2026 00:05:28 +0200 Subject: [PATCH 3/3] fix: prevent renamed IDs from colliding with pre-existing node IDs Reserve all existing node IDs upfront before the disambiguation pass so that a renamed ID never shadows a not-yet-processed node from a different group. Adds a regression test with a fixture where the naive rename would produce a duplicate. Co-authored-by: Cursor --- graphify/extract.py | 763 ++++++++++++++---- .../collision/preexist/proj1/Startup.cs | 2 + .../collision/preexist/proj1_startup.cs | 4 + .../collision/preexist/proj2/Startup.cs | 2 + tests/test_node_id_collision.py | 18 + 5 files changed, 614 insertions(+), 175 deletions(-) create mode 100644 tests/fixtures/collision/preexist/proj1/Startup.cs create mode 100644 tests/fixtures/collision/preexist/proj1_startup.cs create mode 100644 tests/fixtures/collision/preexist/proj2/Startup.cs diff --git a/graphify/extract.py b/graphify/extract.py index 290e5cd5b..eb38704eb 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -384,7 +384,7 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: nodes: list, edges: list, seen_ids: set, function_bodies: list, parent_class_nid: str | None, add_node_fn, add_edge_fn, walk_fn) -> bool: - """Handle namespace_declaration for C#. Returns True if handled.""" + """Handle namespace_declaration and constructor_declaration for C#.""" if node.type == "namespace_declaration": name_node = node.child_by_field_name("name") if name_node: @@ -398,6 +398,15 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: for child in body.children: walk_fn(child, parent_class_nid) return True + if node.type == "constructor_declaration": + owner = parent_class_nid or file_nid + body = node.child_by_field_name("body") + if body: + function_bodies.append((owner, body)) + return True + if node.type == "global_statement": + function_bodies.append((file_nid, node)) + return True return False @@ -519,15 +528,16 @@ def _swift_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: s class_types=frozenset({"class_declaration", "interface_declaration"}), function_types=frozenset({"method_declaration"}), import_types=frozenset({"using_directive"}), - call_types=frozenset({"invocation_expression"}), + call_types=frozenset({"invocation_expression", "object_creation_expression"}), call_function_field="function", call_accessor_node_types=frozenset({"member_access_expression"}), call_accessor_field="name", body_fallback_child_types=("declaration_list",), - function_boundary_types=frozenset({"method_declaration"}), + function_boundary_types=frozenset({"method_declaration", "constructor_declaration"}), import_handler=_import_csharp, ) + _KOTLIN_CONFIG = LanguageConfig( ts_module="tree_sitter_kotlin", class_types=frozenset({"class_declaration", "object_declaration"}), @@ -803,49 +813,6 @@ def walk(node, parent_class_nid: str | None = None) -> None: seen_ids.add(base_nid) add_edge(class_nid, base_nid, "inherits", line) - # Java-specific: extends (superclass) / implements (interfaces) / interface-extends - if config.ts_module == "tree_sitter_java": - def _emit_java_parent(base_name: str, rel: str, at_line: int) -> None: - if not base_name: - return - base_nid = _make_id(stem, base_name) - if base_nid not in seen_ids: - base_nid = _make_id(base_name) - if base_nid not in seen_ids: - nodes.append({ - "id": base_nid, - "label": base_name, - "file_type": "code", - "source_file": "", - "source_location": "", - }) - seen_ids.add(base_nid) - add_edge(class_nid, base_nid, rel, at_line) - - sup = node.child_by_field_name("superclass") - if sup is not None: - for sub in sup.children: - if sub.type == "type_identifier": - _emit_java_parent(_read_text(sub, source), "extends", line) - break - - ifs = node.child_by_field_name("interfaces") - if ifs is not None: - for sub in ifs.children: - if sub.type == "type_list": - for tid in sub.children: - if tid.type == "type_identifier": - _emit_java_parent(_read_text(tid, source), "implements", line) - - if t == "interface_declaration": - for child in node.children: - if child.type == "extends_interfaces": - for sub in child.children: - if sub.type == "type_list": - for tid in sub.children: - if tid.type == "type_identifier": - _emit_java_parent(_read_text(tid, source), "extends", line) - # Find body and recurse body = _find_body(node, config) if body: @@ -1039,6 +1006,18 @@ def walk_calls(node, caller_nid: str) -> None: if child.type == "identifier": callee_name = _read_text(child, source) break + elif config.ts_module == "tree_sitter_c_sharp" and node.type == "object_creation_expression": + # C#: new Type(...) → emit call to the created type + for child in node.children: + if child.type == "identifier": + callee_name = _read_text(child, source) + break + elif child.type == "generic_name": + callee_name = _read_text(child, source).split("<")[0] + break + elif child.type == "qualified_name": + callee_name = _read_text(child, source).split(".")[-1] + break elif config.ts_module == "tree_sitter_c_sharp" and node.type == "invocation_expression": # C#: try name field, then first named child name_node = node.child_by_field_name("name") @@ -1053,6 +1032,18 @@ def walk_calls(node, caller_nid: str) -> None: else: callee_name = raw break + # Extract generic type arguments (e.g. AddDbContext) + full_text = _read_text(node, source) + import re as _re_inline + for gm in _re_inline.finditer(r'<([A-Z][A-Za-z0-9]+)(?:\s*,\s*([A-Z][A-Za-z0-9]+))?>', full_text): + for g in (gm.group(1), gm.group(2)): + if g and g != callee_name: + raw_calls.append({ + "caller_nid": caller_nid, + "callee": g, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) elif config.ts_module == "tree_sitter_php": # PHP: distinguish call expression subtypes if node.type == "function_call_expression": @@ -1428,6 +1419,445 @@ def extract_csharp(path: Path) -> dict: return _extract_generic(path, _CSHARP_CONFIG) +def extract_fsharp(path: Path) -> dict: + """Extract types, functions, modules, and open statements from an F# file.""" + try: + import tree_sitter_fsharp as tsfsharp + from tree_sitter import Language, Parser + language = Language(tsfsharp.language()) + except ImportError: + return {"nodes": [], "edges": [], "error": "tree_sitter_fsharp not installed"} + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + try: + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + function_bodies: list[tuple[str, object]] = [] + + def text(node) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}", + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, "target": tgt, "relation": relation, + "confidence": confidence, "source_file": str_path, + "source_location": f"L{line}", "weight": weight, + }) + + file_nid = _make_id(str(path)) + add_node(file_nid, path.name, 1) + + def _get_type_name(defn_node) -> str | None: + for c in defn_node.children: + if c.type == "type_name": + for tc in c.children: + if tc.type in ("identifier", "long_identifier"): + return text(tc) + return None + + def walk(node, parent_nid: str | None = None) -> None: + t = node.type + + if t == "import_decl": + for child in node.children: + if child.type == "long_identifier": + raw = text(child) + module_name = raw.split(".")[-1].strip() + if module_name: + tgt_nid = _make_id(module_name) + add_edge(file_nid, tgt_nid, "imports", node.start_point[0] + 1) + break + return + + if t == "type_definition": + for sub in node.children: + if sub.type in ("record_type_defn", "union_type_defn", + "anon_type_defn", "abbrev_type_defn", + "class_type_defn", "interface_type_defn"): + type_name = _get_type_name(sub) + if type_name: + line = sub.start_point[0] + 1 + type_nid = _make_id(stem, type_name) + add_node(type_nid, type_name, line) + add_edge(file_nid, type_nid, "contains", line) + + if sub.type == "union_type_defn": + for uc in sub.children: + if uc.type == "union_type_cases": + for case_node in uc.children: + if case_node.type == "union_type_case": + for cc in case_node.children: + if cc.type == "identifier": + case_name = text(cc) + case_nid = _make_id(type_nid, case_name) + qualified_label = f"{type_name}.{case_name}" + add_node(case_nid, qualified_label, case_node.start_point[0] + 1) + add_edge(type_nid, case_nid, "case_of", case_node.start_point[0] + 1) + break + + for child in sub.children: + if child.type in ("member_defn", "function_or_value_defn"): + walk(child, type_nid) + elif child.type == "type_extension_elements": + for elem in child.children: + walk(elem, type_nid) + return + + if t == "module_defn": + name_node = node.child_by_field_name("name") + mod_name = None + if name_node: + mod_name = text(name_node) + else: + for c in node.children: + if c.type in ("identifier", "long_identifier"): + mod_name = text(c) + break + if mod_name: + line = node.start_point[0] + 1 + mod_nid = _make_id(stem, mod_name) + add_node(mod_nid, mod_name, line) + add_edge(file_nid, mod_nid, "contains", line) + block = node.child_by_field_name("block") + if block: + for child in block.children: + walk(child, mod_nid) + else: + for child in node.children: + walk(child, mod_nid) + return + + if t == "function_or_value_defn": + func_name = None + body_node = None + for child in node.children: + if child.type == "function_declaration_left": + for sc in child.children: + if sc.type == "identifier": + func_name = text(sc) + break + body_node = node.child_by_field_name("body") + break + elif child.type == "value_declaration_left": + for sc in child.children: + if sc.type in ("identifier_pattern", "identifier"): + func_name = text(sc) + break + body_node = node.child_by_field_name("body") + break + if func_name: + line = node.start_point[0] + 1 + func_nid = _make_id(stem, func_name) + label = f"{func_name}()" if any( + c.type == "function_declaration_left" for c in node.children + ) else func_name + add_node(func_nid, label, line) + container = parent_nid or file_nid + add_edge(container, func_nid, "contains", line) + if body_node: + function_bodies.append((func_nid, body_node)) + return + + if t == "member_defn": + for child in node.children: + if child.type == "method_or_prop_defn": + member_name = None + body_node = None + found_eq = False + for sc in child.children: + if sc.type == "property_or_ident": + member_name = text(sc) + elif sc.type == "=": + found_eq = True + elif found_eq and sc.is_named and body_node is None: + body_node = sc + if member_name: + line = node.start_point[0] + 1 + member_nid = _make_id(stem, member_name) + add_node(member_nid, f"{member_name}()", line) + if parent_nid: + add_edge(parent_nid, member_nid, "contains", line) + if body_node: + function_bodies.append((member_nid, body_node)) + break + return + + for child in node.children: + walk(child, parent_nid) + + walk(root) + + label_to_nid: dict[str, str] = {} + for n in nodes: + normalised = n["label"].strip("()").lstrip(".") + label_to_nid[normalised.lower()] = n["id"] + + seen_call_pairs: set[tuple[str, str]] = set() + raw_calls: list[dict] = [] + + def walk_calls(node, caller_nid: str) -> None: + if node.type == "application_expression": + for child in node.children: + if child.type == "long_identifier_or_op": + callee = text(child).split(".")[-1] + tgt = label_to_nid.get(callee.lower()) + if tgt and tgt != caller_nid: + pair = (caller_nid, tgt) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + add_edge(caller_nid, tgt, "calls", + node.start_point[0] + 1, confidence="EXTRACTED") + else: + raw_calls.append({ + "caller_nid": caller_nid, "callee": callee, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + break + elif child.type == "dot_expression": + field_node = child.child_by_field_name("field") + if field_node: + callee = text(field_node) + tgt = label_to_nid.get(callee.lower()) + if tgt and tgt != caller_nid: + pair = (caller_nid, tgt) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + add_edge(caller_nid, tgt, "calls", + node.start_point[0] + 1, confidence="EXTRACTED") + else: + raw_calls.append({ + "caller_nid": caller_nid, "callee": callee, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + dot_text = text(child) + root_id = dot_text.split(".")[0] + if root_id and root_id[0].isupper() and root_id != callee: + tgt2 = label_to_nid.get(root_id.lower()) + if tgt2 and tgt2 != caller_nid: + pair2 = (caller_nid, tgt2) + if pair2 not in seen_call_pairs: + seen_call_pairs.add(pair2) + add_edge(caller_nid, tgt2, "calls", + node.start_point[0] + 1, confidence="EXTRACTED") + else: + raw_calls.append({ + "caller_nid": caller_nid, "callee": root_id, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + break + elif child.type in ("identifier", "long_identifier"): + callee = text(child).split(".")[-1] + tgt = label_to_nid.get(callee.lower()) + if tgt and tgt != caller_nid: + pair = (caller_nid, tgt) + if pair not in seen_call_pairs: + seen_call_pairs.add(pair) + add_edge(caller_nid, tgt, "calls", + node.start_point[0] + 1, confidence="EXTRACTED") + else: + raw_calls.append({ + "caller_nid": caller_nid, "callee": callee, + "source_file": str_path, + "source_location": f"L{node.start_point[0] + 1}", + }) + break + for child in node.children: + if child.type == "function_or_value_defn": + continue + walk_calls(child, caller_nid) + + for caller_nid, body in function_bodies: + walk_calls(body, caller_nid) + + clean_edges = [e for e in edges if e["source"] in seen_ids and + (e["target"] in seen_ids or e["relation"] == "imports")] + return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, + "input_tokens": 0, "output_tokens": 0} + + +def extract_razor(path: Path) -> dict: + """Extract component references, @inject services, and @using from Razor/Blazor files.""" + try: + import tree_sitter_razor as ts_razor + from tree_sitter import Language, Parser + language = Language(ts_razor.language()) + except ImportError: + return {"nodes": [], "edges": [], "error": "tree_sitter_razor not installed"} + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + try: + parser = Parser(language) + source = path.read_bytes() + tree = parser.parse(source) + root = tree.root_node + except Exception as e: + return {"nodes": [], "edges": [], "error": str(e)} + + stem = path.stem + str_path = str(path) + nodes: list[dict] = [] + edges: list[dict] = [] + seen_ids: set[str] = set() + raw_calls: list[dict] = [] + + def text(node) -> str: + return source[node.start_byte:node.end_byte].decode("utf-8", errors="replace") + + def add_node(nid: str, label: str, line: int) -> None: + if nid not in seen_ids: + seen_ids.add(nid) + nodes.append({ + "id": nid, "label": label, "file_type": "code", + "source_file": str_path, "source_location": f"L{line}", + }) + + def add_edge(src: str, tgt: str, relation: str, line: int, + confidence: str = "EXTRACTED", weight: float = 1.0) -> None: + edges.append({ + "source": src, "target": tgt, "relation": relation, + "confidence": confidence, "source_file": str_path, + "source_location": f"L{line}", "weight": weight, + }) + + file_nid = _make_id(str(path)) + page_nid = _make_id(stem, stem) + add_node(file_nid, path.name, 1) + add_node(page_nid, stem, 1) + add_edge(file_nid, page_nid, "contains", 1) + + import re as _re + + for child in root.children: + line = child.start_point[0] + 1 + + if child.type == "razor_inject_directive": + for sub in child.children: + if sub.type == "variable_declaration": + type_name = None + for vc in sub.children: + if vc.type == "identifier" and type_name is None: + type_name = text(vc) + elif vc.type == "generic_name": + type_name = text(vc).split("<")[0] + if type_name: + raw_calls.append({ + "caller_nid": page_nid, + "callee": type_name, + "source_file": str_path, + "source_location": f"L{line}", + }) + break + + elif child.type == "razor_using_directive": + ns_parts = [] + for sub in child.children: + if sub.type in ("qualified_name", "identifier"): + ns_parts.append(text(sub)) + if ns_parts: + full_ns = ns_parts[-1] + last_segment = full_ns.split(".")[-1] + tgt_nid = _make_id(last_segment) + add_edge(file_nid, tgt_nid, "imports", line) + + elif child.type == "razor_implements_directive": + iface_name = None + for sub in child.children: + if sub.type in ("identifier", "qualified_name", "generic_name"): + iface_name = text(sub).split(".")[-1].split("<")[0] + if iface_name: + tgt_nid = _make_id(iface_name) + add_edge(page_nid, tgt_nid, "inherits", line) + + elif child.type == "element": + src_text = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + for m in _re.finditer(r'<([A-Z][A-Za-z]+)', src_text): + comp_name = m.group(1) + if comp_name.startswith("Fluent") or comp_name in ( + "PageTitle", "HeadContent", "CascadingValue", + "ErrorBoundary", "Router", "RouteView", "FocusOnNavigate", + "AuthorizeView", "NotFound", "Found", "LayoutView", + "PropertyColumn", "TemplateColumn", + ): + continue + raw_calls.append({ + "caller_nid": page_nid, + "callee": comp_name, + "source_file": str_path, + "source_location": f"L{child.start_point[0] + 1}", + }) + + elif child.type == "razor_block": + block_text = source[child.start_byte:child.end_byte].decode("utf-8", errors="replace") + for m in _re.finditer(r'\b(?:private|protected|public|internal)\s+.*?\s+(\w+)\s*\(', block_text): + method_name = m.group(1) + if method_name in ("new", "get", "set", "value"): + continue + method_nid = _make_id(stem, method_name) + method_line = line + block_text[:m.start()].count('\n') + add_node(method_nid, f"{method_name}()", method_line) + add_edge(page_nid, method_nid, "contains", method_line) + seen_callees: set[str] = set() + for m in _re.finditer(r'\b([A-Z][A-Za-z0-9]+)\.\w+\s*\(', block_text): + callee = m.group(1) + if callee not in seen_callees and callee != stem: + seen_callees.add(callee) + raw_calls.append({ + "caller_nid": page_nid, + "callee": callee, + "source_file": str_path, + "source_location": f"L{line + block_text[:m.start()].count(chr(10))}", + }) + for m in _re.finditer(r'<([A-Z][A-Za-z0-9]+)>', block_text): + callee = m.group(1) + if callee not in seen_callees and callee != stem: + seen_callees.add(callee) + raw_calls.append({ + "caller_nid": page_nid, + "callee": callee, + "source_file": str_path, + "source_location": f"L{line + block_text[:m.start()].count(chr(10))}", + }) + for m in _re.finditer(r'\bnew\s+([A-Z][A-Za-z0-9]+)\s*[(\[{]', block_text): + callee = m.group(1) + if callee not in seen_callees and callee != stem: + seen_callees.add(callee) + raw_calls.append({ + "caller_nid": page_nid, + "callee": callee, + "source_file": str_path, + "source_location": f"L{line + block_text[:m.start()].count(chr(10))}", + }) + + clean_edges = [e for e in edges if e["source"] in seen_ids and + (e["target"] in seen_ids or e["relation"] == "imports")] + return {"nodes": nodes, "edges": clean_edges, "raw_calls": raw_calls, + "input_tokens": 0, "output_tokens": 0} + + def extract_kotlin(path: Path) -> dict: """Extract classes, objects, functions, and imports from a .kt/.kts file.""" return _extract_generic(path, _KOTLIN_CONFIG) @@ -2707,91 +3137,6 @@ def walk_imports(node) -> None: return new_edges -def _resolve_cross_file_java_imports( - per_file: list[dict], - paths: list[Path], -) -> list[dict]: - """Two-pass Java import resolution. - - Pass 1: build a global index {ClassName: [node_id, ...]} across all Java nodes. - Pass 2: re-parse each Java file; for every `import a.b.C;`, resolve C against - the index. Wildcard and stdlib imports produce no edge. - """ - try: - import tree_sitter_java as tsjava - from tree_sitter import Language, Parser - except ImportError: - return [] - - language = Language(tsjava.language()) - parser = Parser(language) - - # Pass 1: class-name → node_id index (only internal, uppercase-starting names) - name_to_ids: dict[str, list[str]] = {} - for file_result in per_file: - for node in file_result.get("nodes", []): - label = node.get("label", "") - nid = node.get("id", "") - src = node.get("source_file", "") - if not label or not nid or not src: - continue - if label.endswith(")") or label.endswith(".java"): - continue - if not label[0].isalpha() or not label[0].isupper(): - continue - name_to_ids.setdefault(label, []).append(nid) - - # Pass 2: resolve imports to real node IDs - new_edges: list[dict] = [] - seen_pairs: set[tuple[str, str]] = set() - for path in paths: - file_nid = _make_id(path.stem) - try: - source = path.read_bytes() - tree = parser.parse(source) - except Exception: - continue - - def walk(n) -> None: - if n.type == "import_declaration": - raw = _read_text(n, source).strip() - body = raw[len("import"):].strip().rstrip(";").strip() - if body.startswith("static "): - body = body[len("static "):].strip() - if body.endswith(".*"): - return - parts = body.split(".") - if not parts: - return - last = parts[-1] - if last and last[0].islower() and len(parts) >= 2: - last = parts[-2] - at_line = n.start_point[0] + 1 - for tgt_nid in name_to_ids.get(last, []): - if tgt_nid == file_nid: - continue - key = (file_nid, tgt_nid) - if key in seen_pairs: - continue - seen_pairs.add(key) - new_edges.append({ - "source": file_nid, - "target": tgt_nid, - "relation": "imports", - "confidence": "EXTRACTED", - "confidence_score": 1.0, - "source_file": str(path), - "source_location": f"L{at_line}", - "weight": 1.0, - }) - for child in n.children: - walk(child) - - walk(tree.root_node) - - return new_edges - - def extract_objc(path: Path) -> dict: """Extract interfaces, implementations, protocols, methods, and imports from .m/.mm/.h files.""" try: @@ -3213,24 +3558,20 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: _check_tree_sitter_version() per_file: list[dict] = [] - # Infer a common root for cache keys (use first diverging segment, not sum of all matches) + # Infer a common root for cache keys try: if not paths: root = Path(".") elif len(paths) == 1: root = paths[0].parent else: - min_parts = min(len(p.parts) for p in paths) - common_len = 0 - for i in range(min_parts): - if len({p.parts[i] for p in paths}) == 1: - common_len += 1 - else: - break + common_len = sum( + 1 for i in range(min(len(p.parts) for p in paths)) + if len({p.parts[i] for p in paths}) == 1 + ) root = Path(*paths[0].parts[:common_len]) if common_len else Path(".") except Exception: root = Path(".") - root = root.resolve() _DISPATCH: dict[str, Any] = { ".py": extract_python, @@ -3250,6 +3591,9 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: ".hpp": extract_cpp, ".rb": extract_ruby, ".cs": extract_csharp, + ".fs": extract_fsharp, + ".fsx": extract_fsharp, + ".razor": extract_razor, ".kt": extract_kotlin, ".kts": extract_kotlin, ".scala": extract_scala, @@ -3300,27 +3644,6 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: all_nodes.extend(result.get("nodes", [])) all_edges.extend(result.get("edges", [])) - # Remap file node IDs from absolute-path-derived to project-relative so - # graph.json edge endpoints are stable across machines (#502) - id_remap: dict[str, str] = {} - for path in paths: - old_id = _make_id(str(path)) - try: - new_id = _make_id(str(path.relative_to(root))) - except ValueError: - continue - if old_id != new_id: - id_remap[old_id] = new_id - if id_remap: - for n in all_nodes: - if n.get("id") in id_remap: - n["id"] = id_remap[n["id"]] - for e in all_edges: - if e.get("source") in id_remap: - e["source"] = id_remap[e["source"]] - if e.get("target") in id_remap: - e["target"] = id_remap[e["target"]] - # Add cross-file class-level edges (Python only - uses Python parser internally) py_paths = [p for p in paths if p.suffix == ".py"] if py_paths: @@ -3332,15 +3655,6 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: import logging logging.getLogger(__name__).warning("Cross-file import resolution failed, skipping: %s", exc) - # Cross-file Java import resolution - java_paths = [p for p in paths if p.suffix == ".java"] - if java_paths: - java_results = [r for r, p in zip(per_file, paths) if p.suffix == ".java"] - try: - all_edges.extend(_resolve_cross_file_java_imports(java_results, java_paths)) - except Exception as exc: - import logging - logging.getLogger(__name__).warning("Java cross-file import resolution failed, skipping: %s", exc) # ── Disambiguate colliding node IDs across files ───────────────────────── # _make_id(stem, name) collides when two files have the same stem (e.g. # Program.cs in different projects). Detect and rename with parent dir, @@ -3353,11 +3667,16 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: # and raw_calls are remapped correctly. global_rename: dict[str, str] = {} dedup_nodes: list[dict] = [] + # Reserve ALL existing IDs upfront so renames never collide with + # not-yet-processed nodes from other groups. + reserved_ids: set[str] = {n["id"] for n in all_nodes} seen_final_ids: set[str] = set() + colliding_ids: set[str] = { + nid for nid, nlist in id_to_files.items() + if len({n["source_file"] for n in nlist if n.get("source_file")}) > 1 + } for nid, node_list in id_to_files.items(): - real_nodes = [n for n in node_list if n.get("source_file")] - unique_files = {n["source_file"] for n in real_nodes} - if len(unique_files) > 1: + if nid in colliding_ids: for n in node_list: sf = n.get("source_file", "") if sf: @@ -3366,13 +3685,15 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: for depth in range(1, len(parts)): prefix = "_".join(parts[-(depth + 1):-1]) candidate = _make_id(prefix, nid) - if candidate not in seen_final_ids: + if candidate not in reserved_ids and candidate not in seen_final_ids: new_id = candidate break if new_id is None: new_id = _make_id("_".join(parts), nid) n["id"] = new_id global_rename[nid + "|" + sf] = new_id + reserved_ids.discard(nid) + reserved_ids.add(new_id) seen_final_ids.add(new_id) dedup_nodes.append(n) else: @@ -3429,10 +3750,100 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: unique_nodes.append(n) all_nodes = unique_nodes + # ── Cross-language node merge ───────────────────────────────────────────── + # C# extractors create stub nodes (empty source_file) for base types that + # may actually be defined in F# files (or other C# files). Merge stubs + # into real definitions so edges point to the canonical node. + # + # Priority: prefer nodes from definition files (Interfaces.fs, Domain.fs) + # over implementation files, so inherits edges point to abstract types. + _DEFINITION_FILES = {"interfaces", "domain", "types", "contracts", "abstractions"} + + real_by_label: dict[str, str] = {} + for n in all_nodes: + sf = n.get("source_file", "") + if sf: + lbl = n["label"].strip("()").lower() + stem_lower = Path(sf).stem.lower() + existing = real_by_label.get(lbl) + if existing is None: + real_by_label[lbl] = n["id"] + elif stem_lower in _DEFINITION_FILES: + real_by_label[lbl] = n["id"] + + stub_ids: set[str] = set() + stub_to_real: dict[str, str] = {} + for n in all_nodes: + if not n.get("source_file"): + lbl = n["label"].strip("()").lower() + real_nid = real_by_label.get(lbl) + if real_nid and real_nid != n["id"]: + stub_to_real[n["id"]] = real_nid + stub_ids.add(n["id"]) + + if stub_to_real: + all_nodes = [n for n in all_nodes if n["id"] not in stub_ids] + for e in all_edges: + if e["source"] in stub_to_real: + e["source"] = stub_to_real[e["source"]] + if e["target"] in stub_to_real: + e["target"] = stub_to_real[e["target"]] + + # ── F# open → file resolution ───────────────────────────────────────────── + # F# `open BKD.Core.Domain` should link to the file that defines the + # Domain module/namespace, not a generic stub. Build a map from the last + # segment of each source file's stem to its file node ID. + file_stem_to_nid: dict[str, str] = {} + for n in all_nodes: + sf = n.get("source_file", "") + if sf and n["label"].endswith((".fs", ".fsx")): + stem_lower = Path(sf).stem.lower() + file_stem_to_nid[stem_lower] = n["id"] + + for e in all_edges: + if e["relation"] == "imports": + tgt_id = e["target"] + tgt_lower = tgt_id.lower() + if tgt_lower in file_stem_to_nid: + e["target"] = file_stem_to_nid[tgt_lower] # Cross-file call resolution for all languages # Each extractor saved unresolved calls in raw_calls. Now that we have all # nodes from all files, resolve any callee that exists in another file. + + _BCL_METHOD_BLOCKLIST = frozenset({ + "contains", "equals", "gethashcode", "tostring", "gettype", + "compareto", "startswith", "endswith", "replace", "split", + "trim", "trimstart", "trimend", "substring", "indexof", + "lastindexof", "insert", "remove", "toarray", "tolist", + "todictionary", "count", "any", "all", "first", "firstordefault", + "last", "lastordefault", "single", "singleordefault", + "where", "select", "selectmany", "orderby", "orderbydescending", + "groupby", "skip", "take", "aggregate", "sum", "min", "max", + "average", "concat", "zip", "distinct", "union", "intersect", + "except", "reverse", "append", "prepend", "add", "clear", + "dispose", "close", "read", "write", "flush", "seek", + "getawaiter", "getresult", "configureawait", + "trygetvalue", "containskey", "containsvalue", + "format", "join", "isnullorempty", "isnullorwhitespace", + "parse", "tryparse", + "listasync", "getasync", "saveasync", "deleteasync", + "updateasync", "createasync", "findasync", "existsasync", + "executeasync", "sendasync", "receiveasync", + "openasync", "closeasync", "readasync", "writeasync", + "createdbcontext", "abortwithstatus", + "abortwithstatuscode", "map", "mapget", "mappost", "mapput", + "mapdelete", "useswagger", "useswaggerui", + "addsingleton", "addscoped", "addtransient", + "useendpoints", "userouting", "useauthorization", + "useauthentication", "usecors", "usehttpsredirection", + "getlogger", "loginformation", "logwarning", "logerror", + "logdebug", "logcritical", + "ok", "notfound", "badrequest", "unauthorized", + "nocontent", "created", "accepted", + "run", "build", "createbuilder", + }) + global_label_to_nid: dict[str, str] = {} for n in all_nodes: raw = n.get("label", "") @@ -3446,6 +3857,8 @@ def extract(paths: list[Path], cache_root: Path | None = None) -> dict: callee = rc.get("callee", "") if not callee: continue + if callee.lower() in _BCL_METHOD_BLOCKLIST: + continue tgt = global_label_to_nid.get(callee.lower()) caller = rc["caller_nid"] if tgt and tgt != caller and (caller, tgt) not in existing_pairs: @@ -3475,7 +3888,7 @@ def collect_files(target: Path, *, follow_symlinks: bool = False, root: Path | N _EXTENSIONS = { ".py", ".js", ".ts", ".tsx", ".go", ".rs", ".java", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", - ".rb", ".cs", ".kt", ".kts", ".scala", ".php", ".swift", + ".rb", ".cs", ".fs", ".fsx", ".razor", ".kt", ".kts", ".scala", ".php", ".swift", ".lua", ".toc", ".zig", ".ps1", ".m", ".mm", } diff --git a/tests/fixtures/collision/preexist/proj1/Startup.cs b/tests/fixtures/collision/preexist/proj1/Startup.cs new file mode 100644 index 000000000..2934d94f4 --- /dev/null +++ b/tests/fixtures/collision/preexist/proj1/Startup.cs @@ -0,0 +1,2 @@ +namespace Proj1; +public class Startup { public void Run() { } } diff --git a/tests/fixtures/collision/preexist/proj1_startup.cs b/tests/fixtures/collision/preexist/proj1_startup.cs new file mode 100644 index 000000000..ca531bb8e --- /dev/null +++ b/tests/fixtures/collision/preexist/proj1_startup.cs @@ -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() { } } diff --git a/tests/fixtures/collision/preexist/proj2/Startup.cs b/tests/fixtures/collision/preexist/proj2/Startup.cs new file mode 100644 index 000000000..dd9e490de --- /dev/null +++ b/tests/fixtures/collision/preexist/proj2/Startup.cs @@ -0,0 +1,2 @@ +namespace Proj2; +public class Startup { public void Init() { } } diff --git a/tests/test_node_id_collision.py b/tests/test_node_id_collision.py index 65192b7cf..404ba9be9 100644 --- a/tests/test_node_id_collision.py +++ b/tests/test_node_id_collision.py @@ -63,3 +63,21 @@ def test_cross_file_edges_remapped(): 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)