diff --git a/graphify/extractors/sln.py b/graphify/extractors/sln.py index 936ff9fff..f169d14e7 100644 --- a/graphify/extractors/sln.py +++ b/graphify/extractors/sln.py @@ -6,6 +6,10 @@ from pathlib import Path from graphify.extractors.base import _make_id +#: Visual Studio's well-known project type GUID for a solution folder -- a +#: grouping that exists only inside the .sln, with no counterpart on disk. +_SOLUTION_FOLDER_TYPE_GUID = "2150e333-8fdc-42a3-9474-1a3956d46de8" + def extract_sln(path: Path) -> dict: """Extract projects and inter-project dependencies from a .sln file.""" @@ -23,32 +27,47 @@ def extract_sln(path: Path) -> dict: seen_ids.add(file_nid) _PROJECT_RE = re.compile( - r'Project\("[^"]*"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"' + r'Project\("([^"]*)"\)\s*=\s*"([^"]+)"\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"' ) _DEP_RE = re.compile(r'\{([0-9a-fA-F-]+)\}\s*=\s*\{([0-9a-fA-F-]+)\}') guid_to_nid: dict[str, str] = {} for m in _PROJECT_RE.finditer(src): - proj_name = m.group(1) - proj_path = m.group(2).replace("\\", "/") - proj_guid = m.group(3).strip("{}") + proj_type = m.group(1).strip("{}").lower() + proj_name = m.group(2) + proj_path = m.group(3).replace("\\", "/") + proj_guid = m.group(4).strip("{}") + + # A solution folder is a VIRTUAL grouping declared only inside the .sln -- + # there is no such directory on disk -- so it is not a file and does not + # become a node. Visual Studio marks one with this project type GUID, and + # writes the folder name in the path position, e.g. + # + # Project("{2150E333-...}") = "Solution Items", "Solution Items", "{C40B...}" + # + # against a real project's relative path: + # + # Project("{8BC9CEB8-...}") = "jpgfltr", "jpgfltr\jpgfltr.vcproj", "{EA73...}" + # + # Emitting it produced a node whose source_file was a bare name with no + # directory component and a null source_location, which reads downstream as + # a stray unignored top-level path -- and no .graphifyignore pattern can + # match it, because there is no path to match. + # + # This supersedes the earlier `proj_path == proj_name` heuristic added for + # #1789 (resolving a folder to an absolute path leaked the scan path, and + # the OS username with it, into graph.json). Skipping the entry entirely + # closes that leak too, and keys off the field that actually states what + # the entry is rather than a coincidence between two other fields -- a real + # project whose path equals its name would have tripped the old test. + if proj_type == _SOLUTION_FOLDER_TYPE_GUID: + continue - # A solution folder is a VIRTUAL grouping, not a file: Visual Studio writes - # its name as both the display name and the "path" (proj_name == proj_path, - # no real file). Resolving it to an absolute path and keying the node id off - # that leaked the absolute scan path (incl. the OS username) into graph.json, - # because the CLI's id-relativization only remaps ids of real files in the - # scan set — a virtual folder never matches, so its absolute id survived - # (#1789). Use the folder name itself (relative, no filesystem resolution). - is_solution_folder = proj_path == proj_name - if is_solution_folder: - abs_proj = proj_name - else: - try: - abs_proj = str((path.parent / proj_path).resolve()) - except Exception: - abs_proj = proj_path + try: + abs_proj = str((path.parent / proj_path).resolve()) + except Exception: + abs_proj = proj_path proj_nid = _make_id(abs_proj) if proj_nid and proj_nid not in seen_ids: seen_ids.add(proj_nid) diff --git a/tests/test_dotnet.py b/tests/test_dotnet.py index 876bb4295..f25455eb2 100644 --- a/tests/test_dotnet.py +++ b/tests/test_dotnet.py @@ -45,18 +45,25 @@ def test_sln_project_dependency(): assert "imports" in _relations(r) -def test_sln_solution_folder_ids_are_relative(tmp_path): - """Solution folders are virtual groupings, not files. Their node ids must be - derived from the folder name only — never the resolved absolute scan path, - which would leak the local username into a committed graph.json (#1789).""" +def test_sln_solution_folder_is_not_a_node(tmp_path): + """A solution folder is a virtual grouping declared only inside the .sln, so + it is not a file and does not become a node. + + Supersedes the previous behaviour, which emitted the folder with an id derived + from its name so that the resolved absolute scan path (and the local username + with it) could not leak into a committed graph.json (#1789). Not emitting the + entry at all satisfies that more directly, and keys off the project type GUID + -- the field that states what the entry is -- rather than the coincidence that + a folder repeats its name in the path position. + """ sln = tmp_path / "App.sln" sln.write_text( 'Microsoft Visual Studio Solution File, Format Version 12.00\n' - # a solution folder: type GUID 2150E333-... , name == path, no real file + # a solution folder: type GUID 2150E333-..., name repeated as the path 'Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Plugins", "Plugins", ' '"{11111111-1111-1111-1111-111111111111}"\n' 'EndProject\n' - # a real project resolves to an absolute path as before + # a real project still resolves to its path as before 'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "App", "App\\App.csproj", ' '"{22222222-2222-2222-2222-222222222222}"\n' 'EndProject\n', @@ -64,14 +71,30 @@ def test_sln_solution_folder_ids_are_relative(tmp_path): ) r = extract_sln(sln) assert "error" not in r - # The virtual solution folder must be keyed off its name, with no trace of the - # absolute scan path. (Real-file nodes — the .sln and .csproj — legitimately - # carry absolute ids here; the CLI's id-relativization pass remaps those, but - # never the virtual folder, which is why the leak had to be fixed at source.) - folder = next(n for n in r["nodes"] if n["label"] == "Plugins") - assert folder["id"] == "plugins" - assert folder["source_file"] == "Plugins" - assert str(tmp_path) not in folder["id"] + labels = {n["label"] for n in r["nodes"]} + assert "Plugins" not in labels + assert "App" in labels + # nothing anywhere in the result may carry the absolute scan path (#1789) + assert all(str(tmp_path) not in n["id"] for n in r["nodes"] if n["label"] == "Plugins") + + +def test_sln_project_named_like_its_path_is_still_a_node(tmp_path): + """The old folder test was `path == name`, which a real project can satisfy. + + A project whose relative path happens to equal its display name is a file and + must still be extracted; only the type GUID decides. + """ + sln = tmp_path / "App.sln" + sln.write_text( + 'Microsoft Visual Studio Solution File, Format Version 12.00\n' + 'Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tools", "Tools", ' + '"{33333333-3333-3333-3333-333333333333}"\n' + 'EndProject\n', + encoding="utf-8", + ) + r = extract_sln(sln) + assert "error" not in r + assert "Tools" in {n["label"] for n in r["nodes"]} # ── .slnx ────────────────────────────────────────────────────────────────────