diff --git a/src/projectmem/commands/visualize.py b/src/projectmem/commands/visualize.py index 580859d..a8a349b 100644 --- a/src/projectmem/commands/visualize.py +++ b/src/projectmem/commands/visualize.py @@ -23,7 +23,8 @@ def run( mem_dir = require_mem_dir(root) # 1. Build the graph data - graph_data = build_graph_data(events) + project_root = mem_dir.parent + graph_data = build_graph_data(events, root=project_root) # 2. Read PROJECT_MAP.md for the Project Map tab map_path = project_map_path(root) @@ -67,6 +68,47 @@ def run( webbrowser.open(viz_path.as_uri()) +def _location_path_for_graph( + location: str | None, + root: Path | None = None, +) -> str | None: + """Return a project-relative path for Story Map linking, if path-like.""" + if not location: + return None + + raw = location.strip().strip('"').strip("'") + if not raw: + return None + + if ":" in raw: + head, tail = raw.split(":", 1) + if tail.strip().split(":", 1)[0].isdigit(): + raw = head + + normalized = raw.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + normalized = normalized.strip("/") + + if not normalized: + return None + + root_path = root or Path.cwd() + candidate = root_path / normalized + if candidate.is_file(): + return normalized + if candidate.is_dir(): + return normalized.rstrip("/") + "/" + + name = Path(normalized).name + is_file_like = "." in name and " " not in normalized + has_path_separator = "/" in normalized + if is_file_like and has_path_separator: + return normalized + + return None + + def build_project_map_graph(map_text: str) -> dict[str, Any]: nodes = [] links = [] @@ -144,7 +186,10 @@ def build_timeline_data(events: list[Event]) -> list[dict[str, Any]]: return timeline -def build_graph_data(events: list[Event]) -> dict[str, Any]: +def build_graph_data( + events: list[Event], + root: Path | None = None, +) -> dict[str, Any]: nodes = [] links = [] @@ -170,11 +215,11 @@ def add_file(path: str): for event in events: for f in event.files: add_file(f) - if event.location and ":" in event.location: - f = event.location.split(":")[0] - add_file(f) + location_file = _location_path_for_graph(event.location, root=root) + if location_file: + add_file(location_file) if event.outcome == "failed": - failure_counts[f] = failure_counts.get(f, 0) + 1 + failure_counts[location_file] = failure_counts.get(location_file, 0) + 1 # Update failure counts in nodes for node in nodes: @@ -206,9 +251,9 @@ def add_file(path: str): links.append({"source": event_id, "target": f, "type": "mention"}) # Link event to its location file - if event.location and ":" in event.location: - f = event.location.split(":")[0] - links.append({"source": event_id, "target": f, "type": "at"}) + location_file = _location_path_for_graph(event.location, root=root) + if location_file: + links.append({"source": event_id, "target": location_file, "type": "at"}) # 3. Calculate ROI stats raw_events = [e.__dict__ for e in events] diff --git a/tests/test_visualize.py b/tests/test_visualize.py new file mode 100644 index 0000000..ad1a35d --- /dev/null +++ b/tests/test_visualize.py @@ -0,0 +1,162 @@ +from __future__ import annotations + +from pathlib import Path + +from projectmem.commands.visualize import _location_path_for_graph, build_graph_data +from projectmem.models import Event + + +def test_location_path_for_graph_accepts_file_path_without_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "agent_portability_kit" / "importers" / "neutral.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + + result = _location_path_for_graph( + "src/agent_portability_kit/importers/neutral.py", + root=tmp_path, + ) + + assert result == "src/agent_portability_kit/importers/neutral.py" + + +def test_location_path_for_graph_accepts_file_path_with_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "projectmem" / "cli.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + + result = _location_path_for_graph("src/projectmem/cli.py:42", root=tmp_path) + + assert result == "src/projectmem/cli.py" + + +def test_location_path_for_graph_normalizes_windows_separators(tmp_path: Path) -> None: + source = tmp_path / "src" / "projectmem" / "cli.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + + result = _location_path_for_graph(r".\src\projectmem\cli.py", root=tmp_path) + + assert result == "src/projectmem/cli.py" + + +def test_location_path_for_graph_accepts_existing_directory(tmp_path: Path) -> None: + tests_dir = tmp_path / "tests" + tests_dir.mkdir() + + result = _location_path_for_graph("tests", root=tmp_path) + + assert result == "tests/" + + +def test_location_path_for_graph_rejects_descriptive_locations(tmp_path: Path) -> None: + assert _location_path_for_graph("projectmem pre-commit hook", root=tmp_path) is None + assert _location_path_for_graph("docs current-state", root=tmp_path) is None + assert _location_path_for_graph("deploy pipeline", root=tmp_path) is None + + +def _node_ids(graph: dict) -> set[str]: + return {node["id"] for node in graph["nodes"]} + + +def _link_tuples(graph: dict) -> set[tuple[str, str, str]]: + return { + (link["source"], link["target"], link["type"]) + for link in graph["links"] + } + + +def test_build_graph_data_links_path_like_location_without_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "agent_portability_kit" / "importers" / "neutral.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + event = Event( + id="evt_24214d5d29b2483e8da2", + type="attempt", + issue_id="0008", + location="src/agent_portability_kit/importers/neutral.py", + outcome="worked", + summary=( + "Tightened neutral import to reject missing required fields, extra " + "closed-shape keys, wrong MCP string types, and forbidden " + "transport-branch keys" + ), + ) + + graph = build_graph_data([event], root=tmp_path) + + assert "evt_24214d5d29b2483e8da2" in _node_ids(graph) + assert "src/agent_portability_kit/importers/neutral.py" in _node_ids(graph) + assert ( + "evt_24214d5d29b2483e8da2", + "src/agent_portability_kit/importers/neutral.py", + "at", + ) in _link_tuples(graph) + + +def test_build_graph_data_keeps_descriptive_location_unlinked(tmp_path: Path) -> None: + event = Event( + id="evt_descriptive", + type="note", + location="projectmem pre-commit hook", + summary="Windows CP1252 terminal output can fail on box drawing characters", + ) + + graph = build_graph_data([event], root=tmp_path) + + assert "evt_descriptive" in _node_ids(graph) + assert "projectmem pre-commit hook" not in _node_ids(graph) + assert graph["links"] == [] + + +def test_build_graph_data_counts_failed_attempts_for_path_like_location( + tmp_path: Path, +) -> None: + source = tmp_path / "src" / "projectmem" / "commands" / "visualize.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + event = Event( + id="evt_failed", + type="attempt", + location="src/projectmem/commands/visualize.py", + outcome="failed", + summary="Tried linking only file:line locations and left file-only events floating", + ) + + graph = build_graph_data([event], root=tmp_path) + + file_node = next( + node + for node in graph["nodes"] + if node["id"] == "src/projectmem/commands/visualize.py" + ) + assert file_node["failures"] == 1 + + +def test_build_graph_data_still_links_explicit_files(tmp_path: Path) -> None: + event = Event( + id="evt_files", + type="fix", + files=["README.md", "src/projectmem/cli.py"], + summary="Backfilled commit touched README and CLI", + ) + + graph = build_graph_data([event], root=tmp_path) + + assert ("evt_files", "README.md", "mention") in _link_tuples(graph) + assert ("evt_files", "src/projectmem/cli.py", "mention") in _link_tuples(graph) + + +def test_build_graph_data_still_links_location_with_line(tmp_path: Path) -> None: + source = tmp_path / "src" / "projectmem" / "cli.py" + source.parent.mkdir(parents=True) + source.write_text("# fixture\n", encoding="utf-8") + event = Event( + id="evt_line", + type="issue", + location="src/projectmem/cli.py:210", + summary="Visualize command needs graph payload fix", + ) + + graph = build_graph_data([event], root=tmp_path) + + assert ("evt_line", "src/projectmem/cli.py", "at") in _link_tuples(graph)