Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 54 additions & 9 deletions src/projectmem/commands/visualize.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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 = []

Expand All @@ -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:
Expand Down Expand Up @@ -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]
Expand Down
162 changes: 162 additions & 0 deletions tests/test_visualize.py
Original file line number Diff line number Diff line change
@@ -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)