diff --git a/graphify/extractors/engine.py b/graphify/extractors/engine.py index d865b4f98..f7b61191c 100644 --- a/graphify/extractors/engine.py +++ b/graphify/extractors/engine.py @@ -3244,6 +3244,18 @@ def _ts_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, return True return False +def _csharp_scope_id(node) -> str: + """Id of a namespace scope, stored in ``scope_chain`` / ``scope_id`` metadata. + + Row and column, not ``start_byte``: a CRLF checkout has one more byte per + line than an LF checkout of the same file, so a byte offset gave a + Windows and a Linux build of the same commit different graph.json + metadata. Row and column are the same for both and just as unique. + """ + row, column = node.start_point + return f"s{row}:{column}" + + def _csharp_namespace_name(node, source: bytes) -> str: name_node = node.child_by_field_name("name") if name_node is not None: @@ -3286,7 +3298,7 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: pushed = False if ns_name: namespace_stack.append(ns_name) - scope_stack.append(f"s{node.start_byte}") + scope_stack.append(_csharp_scope_id(node)) pushed = True ns_label = ".".join(namespace_stack) ns_nid = _csharp_namespace_id(ns_label) @@ -3310,7 +3322,7 @@ def _csharp_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: ns_name = _csharp_namespace_name(node, source) if ns_name: namespace_stack.append(ns_name) - scope_stack.append(f"s{node.start_byte}") + scope_stack.append(_csharp_scope_id(node)) ns_label = ".".join(namespace_stack) ns_nid = _csharp_namespace_id(ns_label) line = node.start_point[0] + 1 diff --git a/tests/test_csharp_type_resolution.py b/tests/test_csharp_type_resolution.py index 34487d704..6db6e8d8e 100644 --- a/tests/test_csharp_type_resolution.py +++ b/tests/test_csharp_type_resolution.py @@ -765,3 +765,57 @@ def test_csharp_name_resolver_resolves_type_in_partial_graph_without_contains(): ) assert resolved == "lib::type", "Valid C# type must resolve even without `contains` edges" assert decisive is True + + +def _crlf_vs_lf(tmp_path: Path, files: dict[str, str]) -> tuple[dict, dict]: + results = [] + for name, newline in (("lf", "\n"), ("crlf", "\r\n")): + root = tmp_path / name + paths = [] + for rel, text in files.items(): + p = root / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_bytes(text.replace("\n", newline).encode("utf-8")) + paths.append(p) + results.append(extract(paths, root=root, cache_root=root)) + return results[0], results[1] + + +def test_csharp_scope_metadata_does_not_depend_on_line_endings(tmp_path: Path): + """Namespace scope ids were byte offsets, so a CRLF checkout (Windows, + core.autocrlf) and an LF checkout of the same commit wrote different + `scope_chain` / `scope_id` metadata into graph.json. Row:column is the + same for both.""" + files = { + "core.cs": "// Core types.\nusing System;\n\nnamespace Game.Core\n{\n public class Damage {}\n}\n", + "combat.cs": ( + "// Combat.\n" + "using System;\n" + "\n" + "namespace Game.Combat\n" + "{\n" + " using Dmg = Game.Core.Damage;\n" + "\n" + " public class Weapon : Dmg\n" + " {\n" + " public void Hit() {}\n" + " }\n" + "}\n" + ), + } + lf, crlf = _crlf_vs_lf(tmp_path, files) + + def shape(result: dict) -> tuple[list, list]: + nodes = sorted( + (n["id"], repr(sorted((n.get("metadata") or {}).items())), n.get("source_location")) + for n in result["nodes"] + ) + edges = sorted((e["source"], e["target"], e["relation"]) for e in result["edges"]) + return nodes, edges + + assert any("scope_chain" in (n.get("metadata") or {}) for n in lf["nodes"]) + assert shape(lf) == shape(crlf) + damage = _targets(crlf, "inherits", "Damage") + assert damage and all("core.cs" in d["source_file"] for d in damage), ( + "the namespace-scoped alias must still resolve in a CRLF file" + )