diff --git a/graphify/cargo_introspect.py b/graphify/cargo_introspect.py index 026c50e94..1a5e6ba6c 100644 --- a/graphify/cargo_introspect.py +++ b/graphify/cargo_introspect.py @@ -86,6 +86,10 @@ def introspect_cargo(root: str | Path) -> dict[str, Any]: if discovered is not None: root_manifest = discovered root_data = _load_toml(root_manifest) + workspace = root_data.get("workspace") + workspace_dependencies = workspace.get("dependencies", {}) if isinstance(workspace, dict) else {} + if not isinstance(workspace_dependencies, dict): + workspace_dependencies = {} # Workspace members are resolved relative to the manifest's OWN # directory, not necessarily root_path (#3678: the manifest can live in @@ -119,6 +123,13 @@ def introspect_cargo(root: str | Path) -> dict[str, Any]: continue source_file = manifest.relative_to(root_path).as_posix() for dep_key, spec in sorted(dependencies.items()): + inherited = isinstance(spec, dict) and "workspace" in spec + if inherited: + if spec["workspace"] is not True: + continue + spec = workspace_dependencies.get(dep_key) + if not isinstance(spec, dict) or not isinstance(spec.get("path"), str): + continue # Cargo lets a dep table entry rename the crate via `package = "..."`: # db = { path = "../storage", package = "internal-storage" } # The key `db` is the name used in `use db::…;`; the actual crate @@ -133,6 +144,12 @@ def introspect_cargo(root: str | Path) -> dict[str, Any]: target = crates.get(real_name) if target is None: continue + if inherited: + # Shared paths are relative to the workspace root, not the member. + # A matching name alone cannot prove a registry/git dep is local. + inherited_manifest = (root_path / spec["path"] / "Cargo.toml").resolve() + if inherited_manifest != target[1].resolve(): + continue edges.append( { "source": source_id, diff --git a/tests/test_cargo_introspect.py b/tests/test_cargo_introspect.py index 7b6779dda..34b064b43 100644 --- a/tests/test_cargo_introspect.py +++ b/tests/test_cargo_introspect.py @@ -565,3 +565,119 @@ edition = "2021" with pytest.raises(FileNotFoundError): introspect_cargo(tmp_path) + + +@pytest.mark.parametrize("root_package", [False, True], ids=["virtual", "root-package"]) +def test_cargo_inherited_rename_uses_workspace_dependency_identity(tmp_path, root_package): + root_header = '[package]\nname = "root-app"\nversion = "0.1.0"\n' if root_package else "" + root_dependency = '[dependencies]\ndb.workspace = true\n' if root_package else "" + _write_manifest( + tmp_path / "Cargo.toml", + root_header + + '[workspace]\nmembers = ["app", "storage"]\n' + + '[workspace.dependencies]\n' + + 'db = { path = "storage", package = "internal-storage" }\n' + + root_dependency, + ) + app = tmp_path / "app" + storage = tmp_path / "storage" + app.mkdir() + storage.mkdir() + _write_manifest( + app / "Cargo.toml", + '[package]\nname = "app"\nversion = "0.1.0"\n' + '[dependencies]\ndb = { workspace = true, features = ["fast"] }\n', + ) + _write_manifest( + storage / "Cargo.toml", + '[package]\nname = "internal-storage"\nversion = "0.1.0"\n' + '[features]\nfast = []\n', + ) + + result = introspect_cargo(tmp_path) + expected_sources = {"crate:app"} + if root_package: + expected_sources.add("crate:root-app") + assert {node["id"] for node in result["nodes"]} == expected_sources | {"crate:internal-storage"} + assert len(result["edges"]) == len(expected_sources) + assert {(edge["source"], edge["target"]) for edge in result["edges"]} == { + (source, "crate:internal-storage") for source in expected_sources + } + for edge in result["edges"]: + assert edge["source_file"] == ("Cargo.toml" if edge["source"] == "crate:root-app" else "app/Cargo.toml") + assert edge["relation"] == "crate_depends_on" + assert edge["context"] == "cargo_dependency" + assert edge["confidence"] == "EXTRACTED" + assert introspect_cargo(tmp_path) == result + + +@pytest.mark.parametrize( + ("root_declaration", "member_declaration", "expected"), + [ + ('db = { path = "db" }', 'db.workspace = true', True), + ('db = "1"', 'db.workspace = true', False), + ('db = { version = "1" }', 'db.workspace = true', False), + ('db = { git = "https://example.com/db" }', 'db.workspace = true', False), + ('db = { path = "missing" }', 'db.workspace = true', False), + ('db = { path = "app" }', 'db.workspace = true', False), + ('db = { path = 42 }', 'db.workspace = true', False), + ('db = false', 'db.workspace = true', False), + ('', 'db.workspace = true', False), + ('db = { path = "db" }', 'db.workspace = false', False), + ('db = { path = "db" }', 'db.workspace = "true"', False), + ('db = { path = "missing" }', 'db = { path = "../db" }', True), + ], +) +def test_cargo_inherited_dependency_requires_matching_local_path( + tmp_path, root_declaration, member_declaration, expected +): + _write_manifest( + tmp_path / "Cargo.toml", + '[workspace]\nmembers = ["app", "db"]\n' + '[workspace.dependencies]\n' + root_declaration + '\n', + ) + for name in ("app", "db"): + crate = tmp_path / name + crate.mkdir() + _write_manifest( + crate / "Cargo.toml", + f'[package]\nname = "{name}"\nversion = "0.1.0"\n' + + (f'[dependencies]\n{member_declaration}\n' if name == "app" else ""), + ) + + result = introspect_cargo(tmp_path) + assert {node["id"] for node in result["nodes"]} == {"crate:app", "crate:db"} + assert [(edge["source"], edge["target"]) for edge in result["edges"]] == ( + [("crate:app", "crate:db")] if expected else [] + ) + + +@pytest.mark.parametrize("root_path", [".", ""]) +def test_cargo_inherited_dependency_can_target_root_package(tmp_path, root_path): + _write_manifest( + tmp_path / "Cargo.toml", + '[package]\nname = "db"\nversion = "0.1.0"\n' + '[workspace]\nmembers = ["app"]\n' + f'[workspace.dependencies]\ndb = {{ path = "{root_path}" }}\n', + ) + app = tmp_path / "app" + app.mkdir() + _write_manifest( + app / "Cargo.toml", + '[package]\nname = "app"\nversion = "0.1.0"\n' + '[dependencies]\ndb.workspace = true\n', + ) + result = introspect_cargo(tmp_path) + assert [(edge["source"], edge["target"]) for edge in result["edges"]] == [ + ("crate:app", "crate:db") + ] + + +@pytest.mark.parametrize("workspace", ["", "workspace = false\n", "[workspace]\n", "[workspace]\ndependencies = false\n"]) +def test_cargo_missing_or_malformed_workspace_dependencies_are_ignored(tmp_path, workspace): + _write_manifest( + tmp_path / "Cargo.toml", + workspace + '[package]\nname = "db"\nversion = "0.1.0"\n' + '[dependencies]\ndb.workspace = true\n', + ) + assert introspect_cargo(tmp_path)["edges"] == []