diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c3e8767dc..7371bbf609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu - Fix: the incremental rebuild no longer purges AST nodes it just reported as fail-closed "kept" — the eviction pass re-checks the kept set, so a moved-file/symlink layout can't deadlock the shrink guard into refusing every update (#3697, #3695, thanks @hopstreax). - Fix: `graph.html` no longer crashes vis-network with a stack overflow on large graphs — nodes are seeded on a spiral before physics runs so overlap-avoidance can't blow the layout recursion (#3699, thanks @sanjaiyan-dev). - Fix: node and edge tooltips now show special characters literally (C++ templates like `vector`, generics, `&`, quotes) instead of raw HTML entities, while the HTML sinks that need escaping keep it (#3686, #3664, thanks @hopstreax). +- Fix: `graphify extract --cargo` now finds a `Cargo.toml` one or two levels below the scan root (a Tauri app's crate, for example, lives one level down from the project root) instead of only ever looking at the scan root itself, so a repository whose crate isn't at the top no longer gets zero crate nodes and zero dependency edges (#3678, thanks @ExhibitJ). - Docs: repository links now point at `Graphify-Labs/graphify` instead of the old account (including in generated wiki output), translated READMEs use the current logo, GitHub issue/PR templates were added, and the Enterprise link was corrected (#3692, #3694, #3693, thanks @Abdul535). ## 0.9.64 (2026-09-18) diff --git a/graphify/cargo_introspect.py b/graphify/cargo_introspect.py index fa03ed309e..026c50e940 100644 --- a/graphify/cargo_introspect.py +++ b/graphify/cargo_introspect.py @@ -2,11 +2,13 @@ from __future__ import annotations +import os from pathlib import Path from typing import Any _CONFIDENCE_EXTRACTED = "EXTRACTED" +_MAX_DISCOVERY_DEPTH = 2 def _load_toml(path: Path) -> dict[str, Any]: @@ -24,6 +26,37 @@ def _load_toml(path: Path) -> dict[str, Any]: return tomllib.load(manifest) +def _discover_root_manifest(root: Path) -> Path | None: + """Find a Cargo.toml under *root* when none sits directly at the root. + + A bounded-depth search rather than an unbounded walk: a Tauri app's + crate lives in src-tauri/, a polyglot repo's in rust/ or crates/, but + going arbitrarily deep risks matching a vendored or example manifest + nested well inside an unrelated dependency (#3678). Reuses the same + noise-directory pruning the AST scanner already applies (venvs, caches, + node_modules, build output) so those never get searched into. A + genuinely ambiguous layout with more than one candidate at the same + depth is resolved by sorted path order rather than guessed at; the + caller is free to point --cargo at a subdirectory directly for a repo + where that is not the right answer. + """ + from graphify.detect import _is_noise_dir + + found: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root): + current = Path(dirpath) + depth = len(current.relative_to(root).parts) + if depth > 0 and "Cargo.toml" in filenames: + found.append(current / "Cargo.toml") + if depth >= _MAX_DISCOVERY_DEPTH: + dirnames[:] = [] + continue + dirnames[:] = [d for d in dirnames if not _is_noise_dir(d, current)] + if not found: + return None + return sorted(found)[0] + + def _member_manifest_paths(root: Path, root_data: dict[str, Any]) -> list[Path]: paths: list[Path] = [] if isinstance(root_data.get("package"), dict): @@ -48,9 +81,16 @@ def introspect_cargo(root: str | Path) -> dict[str, Any]: """Return crate nodes and internal dependency edges from Cargo manifests.""" root_path = Path(root).resolve() root_manifest = root_path / "Cargo.toml" + if not root_manifest.is_file(): + discovered = _discover_root_manifest(root_path) + if discovered is not None: + root_manifest = discovered root_data = _load_toml(root_manifest) - manifests = _member_manifest_paths(root_path, root_data) + # Workspace members are resolved relative to the manifest's OWN + # directory, not necessarily root_path (#3678: the manifest can live in + # a discovered subdirectory, e.g. src-tauri/). + manifests = _member_manifest_paths(root_manifest.parent, root_data) crates: dict[str, tuple[str, Path, dict[str, Any]]] = {} for manifest in manifests: diff --git a/tests/test_cargo_introspect.py b/tests/test_cargo_introspect.py index 9abf82655b..7b6779ddaa 100644 --- a/tests/test_cargo_introspect.py +++ b/tests/test_cargo_introspect.py @@ -455,3 +455,113 @@ def test_cargo_introspect_package_rename_falls_through_when_unresolved(tmp_path) assert {node["id"] for node in result["nodes"]} == {"crate:app"} assert result["edges"] == [] + + +def test_cargo_introspect_discovers_a_manifest_in_a_subdirectory(tmp_path): + """#3678: a Tauri-style layout keeps Cargo.toml in src-tauri/, not at the + scan root. introspect_cargo must find it rather than erroring as if no + crate existed at all.""" + tauri = tmp_path / "src-tauri" + tauri.mkdir() + _write_manifest( + tauri / "Cargo.toml", + """ +[package] +name = "app" +version = "0.1.0" +edition = "2021" +""", + ) + + result = introspect_cargo(tmp_path) + + assert {node["id"] for node in result["nodes"]} == {"crate:app"} + node = next(n for n in result["nodes"] if n["id"] == "crate:app") + assert node["source_file"] == "src-tauri/Cargo.toml", ( + f"the discovered manifest's source_file should stay relative to the " + f"scan root, not the crate subdirectory; got {node['source_file']!r}" + ) + + +def test_cargo_introspect_discovery_resolves_workspace_members_from_the_manifest(tmp_path): + """A discovered workspace manifest's own members are still resolved + relative to ITS directory, not the outer scan root (#3678).""" + tauri = tmp_path / "src-tauri" + tauri.mkdir() + _write_manifest( + tauri / "Cargo.toml", + """ +[workspace] +members = ["core"] +""", + ) + core = tauri / "core" + core.mkdir() + _write_manifest( + core / "Cargo.toml", + """ +[package] +name = "core" +version = "0.1.0" +edition = "2021" +""", + ) + + result = introspect_cargo(tmp_path) + + assert {node["id"] for node in result["nodes"]} == {"crate:core"} + node = next(n for n in result["nodes"] if n["id"] == "crate:core") + assert node["source_file"] == "src-tauri/core/Cargo.toml" + + +def test_cargo_introspect_discovery_skips_noise_directories(tmp_path): + """A Cargo.toml sitting inside node_modules/ (a vendored or example crate + pulled in by an npm dependency) must never win over a real, non-noise + subdirectory (#3678).""" + noise = tmp_path / "node_modules" / "some-pkg" + noise.mkdir(parents=True) + _write_manifest( + noise / "Cargo.toml", + """ +[package] +name = "decoy" +version = "0.1.0" +edition = "2021" +""", + ) + real = tmp_path / "src-tauri" + real.mkdir() + _write_manifest( + real / "Cargo.toml", + """ +[package] +name = "app" +version = "0.1.0" +edition = "2021" +""", + ) + + result = introspect_cargo(tmp_path) + + assert {node["id"] for node in result["nodes"]} == {"crate:app"}, ( + "discovery must never search into node_modules/" + ) + + +def test_cargo_introspect_discovery_does_not_search_past_the_depth_cap(tmp_path): + """A manifest nested deeper than the discovery cap is left unfound rather + than searched for indefinitely (#3678).""" + deep = tmp_path / "a" / "b" / "c" / "d" + deep.mkdir(parents=True) + _write_manifest( + deep / "Cargo.toml", + """ +[package] +name = "too_deep" +version = "0.1.0" +edition = "2021" +""", + ) + + with pytest.raises(FileNotFoundError): + introspect_cargo(tmp_path)