From 32034eccf5310a49a7c60a0e40cc8c8e4aa0ab09 Mon Sep 17 00:00:00 2001 From: rohit-jsfreaky Date: Tue, 6 Oct 2026 19:32:41 +0530 Subject: [PATCH] fix(csharp): make namespace scope ids independent of line endings C# namespace scope ids (the scope_chain / scope_id node metadata a namespace-scoped `using` is matched against) were `s{start_byte}`. A CRLF checkout (Windows with core.autocrlf) has one more byte per line than an LF checkout of the same commit, so the two wrote different metadata into graph.json: on Newtonsoft.Json's 242 source files, 3,096 of 4,352 nodes (71%) differed by line endings alone. Use row:column, which is the same for both and just as unique. The ids are only compared within one file, so resolution is unchanged: all 11,627 edges are identical before and after, and the nodes only differ in the id text (identical once each id is mapped to its scope). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf (cherry picked from commit ffd855a6fa68c74e02137c3c8b236c480b2d7b37) --- graphify/extractors/engine.py | 16 +++++++-- tests/test_csharp_type_resolution.py | 54 ++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) 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" + )