From b5e7edd385697358204ec0b714757f808bac17a7 Mon Sep 17 00:00:00 2001 From: Mike Hanley Date: Fri, 19 Jun 2026 13:31:29 -0500 Subject: [PATCH 1/2] fix: allow fixing a specific issue --- src/projectmem/cli.py | 15 ++++-- src/projectmem/commands/fix.py | 54 ++++++++++++++++++--- src/projectmem/mcp_server.py | 53 ++++++++++++++------- tests/test_log.py | 86 ++++++++++++++++++++++++++++++++++ 4 files changed, 181 insertions(+), 27 deletions(-) diff --git a/src/projectmem/cli.py b/src/projectmem/cli.py index b94b65f..1d1e409 100644 --- a/src/projectmem/cli.py +++ b/src/projectmem/cli.py @@ -106,10 +106,19 @@ def attempt( @app.command() def fix( text: str, - at: str | None = typer.Option(None, "--at", help="Location (e.g. file:line, class.method)"), + at: str | None = typer.Option( + None, + "--at", + help="Location (e.g. file:line, class.method)", + ), + issue: str | None = typer.Option( + None, + "--issue", + help="Close a specific issue ID instead of the active issue (e.g. 0042).", + ), ) -> None: - """Record a fix and close the current issue.""" - fix_command.run(text, location=at) + """Record a fix and close the active issue, or a specific issue with --issue.""" + fix_command.run(text, location=at, issue=issue) @app.command() diff --git a/src/projectmem/commands/fix.py b/src/projectmem/commands/fix.py index 6f92968..330b5d1 100644 --- a/src/projectmem/commands/fix.py +++ b/src/projectmem/commands/fix.py @@ -15,14 +15,51 @@ from projectmem.summary import regenerate_summary -def run(text: str, location: str | None = None) -> Event: - """Close the current issue with a fix. Returns the created fix Event. +def _normalize_issue_id(issue_id: str | None) -> str | None: + """Normalize issue IDs so `1`, `001`, and `0001` all become `0001`.""" + if issue_id is None: + return None + + cleaned = issue_id.strip().lstrip("#") + if not cleaned: + return None + if cleaned.isdigit(): + return cleaned.zfill(4) + return cleaned + + +def _issue_exists(events: list[Event], issue_id: str) -> bool: + """Return True if an issue event exists for the requested issue ID.""" + return any(event.type == "issue" and event.issue_id == issue_id for event in events) + + +def run( + text: str, + location: str | None = None, + issue: str | None = None, +) -> Event: + """Close an issue with a fix. Returns the created fix Event. - Clears the current-issue marker so subsequent `pjm attempt` calls do not - silently re-attach to the just-closed issue (the L-027a misattribution bug). + When `issue` is omitted, this preserves the existing behavior: + close the current issue and clear the current-issue marker. + + When `issue` is provided, the fix is attached to that specific issue. + The current-issue marker is only cleared if it points at the same issue. """ events = read_events() - issue_id = read_current_issue() or current_issue_id(events) + requested_issue_id = _normalize_issue_id(issue) + active_issue_id = read_current_issue() or current_issue_id(events) + + if requested_issue_id is not None: + issue_id = requested_issue_id + if not _issue_exists(events, issue_id): + raise ProjectMemError( + f"Issue #{issue_id} was not found. " + "Run `pjm search ` or `pjm brief` to find the issue ID." + ) + else: + issue_id = active_issue_id + if issue_id is None: raise ProjectMemError("No open issue found. Run `pjm log ` first.") @@ -34,7 +71,12 @@ def run(text: str, location: str | None = None) -> Event: location=location, ) append_event(event) - clear_current_issue() + + # Preserve old behavior when no specific issue was requested. For targeted + # fixes, only clear the active marker if it matches the issue being fixed. + if requested_issue_id is None or active_issue_id == issue_id: + clear_current_issue() + regenerate_summary() typer.echo(f"Fixed issue #{issue_id}") return event diff --git a/src/projectmem/mcp_server.py b/src/projectmem/mcp_server.py index 01b50bc..8ce9d9a 100644 --- a/src/projectmem/mcp_server.py +++ b/src/projectmem/mcp_server.py @@ -154,7 +154,7 @@ def wrapper(*args, **kwargs): "directly via filesystem write:\n" " - On a bug discovery → log_issue(summary, location).\n" " - After each fix attempt → record_attempt(summary, outcome).\n" - " - After confirmation → record_fix(summary).\n" + " - After confirmation → use record_fix(summary) for the active issue. If fixing a specific older issue, use record_fix(summary, issue_id=\"\") and replace with the actual Projectmem issue ID.\n" " - On a design choice → add_decision(summary).\n" " - On a gotcha / setup detail → add_note(summary).\n" "Editing .projectmem/summary.md or .projectmem/PROJECT_MAP.md\n" @@ -509,26 +509,43 @@ def record_attempt( @mcp.tool() @safe_tool def record_fix( - summary: Annotated[str, Field( - description="One-line description of the confirmed fix (e.g., " - "'guarded submit handler with isSubmitting ref')." - )], - location: Annotated[Optional[str], Field( - description="Optional file path or component where the fix was " - "applied." - )] = None, + summary: Annotated[ + str, + Field( + description=( + "One-line description of the confirmed fix " + "(e.g., 'guarded submit handler with isSubmitting ref')." + ) + ), + ], + location: Annotated[ + Optional[str], + Field( + description="Optional file path or component where the fix was applied." + ), + ] = None, + issue_id: Annotated[ + Optional[str], + Field( + description=( + "Optional zero-padded issue ID (e.g., '0042') to close. " + "When omitted, closes the active issue. Numeric strings without " + "padding are accepted." + ) + ), + ] = None, ) -> str: - """Record a confirmed fix and close the current issue. + """Record a confirmed fix and close an issue. + + Only call AFTER you have evidence the fix works: test passes, error is gone, + or the user confirmed. - Only call AFTER you have evidence the fix works (test passes, error - gone, user confirmed). Closing the issue clears the active-issue - marker so the next record_attempt won't silently latch onto this - closed issue (L-027a). + If `issue_id` is provided, the fix is attached to that specific issue. + If `issue_id` is omitted, the active issue is closed. - Side effects: appends a `fix` event, closes the active issue, and - clears the active-issue marker so the next record_attempt won't - silently latch onto a closed issue.""" - event = fix.run(summary, location=location) + Side effects: appends a `fix` event and updates summary.md. The active-issue + marker is cleared only when the active issue is the issue being fixed.""" + event = fix.run(summary, location=location, issue=issue_id) return f"Fixed issue #{event.issue_id}: {summary}" diff --git a/tests/test_log.py b/tests/test_log.py index df7b637..e9aac80 100644 --- a/tests/test_log.py +++ b/tests/test_log.py @@ -34,6 +34,92 @@ def test_log_attempt_and_fix_write_events(tmp_path, monkeypatch): assert events[1]["outcome"] == "failed" +def test_fix_issue_closes_target_without_clearing_newer_active_issue( + tmp_path, monkeypatch +): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "old issue"], catch_exceptions=False) + runner.invoke(app, ["log", "new issue"], catch_exceptions=False) + + result = runner.invoke( + app, + ["fix", "fixed old issue", "--issue", "1"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Fixed issue #0001" in result.stdout + assert ( + tmp_path / ".projectmem" / ".current_issue" + ).read_text(encoding="utf-8") == "0002" + + events = [ + json.loads(line) + for line in (tmp_path / ".projectmem" / "events.jsonl") + .read_text(encoding="utf-8") + .splitlines() + ] + assert events[-1]["type"] == "fix" + assert events[-1]["issue_id"] == "0001" + + +def test_plain_fix_still_closes_active_issue(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "active issue"], catch_exceptions=False) + + result = runner.invoke(app, ["fix", "fixed active"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "Fixed issue #0001" in result.stdout + assert not (tmp_path / ".projectmem" / ".current_issue").exists() + + +def test_fix_issue_rejects_unknown_issue(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "known issue"], catch_exceptions=False) + + result = runner.invoke(app, ["fix", "missing issue", "--issue", "42"]) + + assert result.exit_code == 1 + assert result.exception is not None + assert "Issue #0042 was not found" in str(result.exception) + + +def test_mcp_record_fix_accepts_target_issue_id(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + runner = CliRunner() + + runner.invoke(app, ["init"], catch_exceptions=False) + runner.invoke(app, ["log", "old issue"], catch_exceptions=False) + runner.invoke(app, ["log", "new issue"], catch_exceptions=False) + + from projectmem.mcp_server import record_fix + + result = record_fix("fixed old issue through MCP", issue_id="1") + + assert result == "Fixed issue #0001: fixed old issue through MCP" + assert ( + tmp_path / ".projectmem" / ".current_issue" + ).read_text(encoding="utf-8") == "0002" + + last = json.loads( + (tmp_path / ".projectmem" / "events.jsonl") + .read_text(encoding="utf-8") + .splitlines()[-1] + ) + assert last["type"] == "fix" + assert last["issue_id"] == "0001" + + def test_attempt_without_open_issue_fails(tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) runner = CliRunner() From 83100ce1ce89a9b2cf935d40ac77b798330ff3fe Mon Sep 17 00:00:00 2001 From: hanley-development Date: Sat, 20 Jun 2026 02:36:06 -0500 Subject: [PATCH 2/2] feat: improve story map readability controls --- .gitignore | 1 + src/projectmem/commands/visualize.py | 745 ++++++++++++++++++++++----- tests/test_visualize.py | 315 +++++++++++ 3 files changed, 941 insertions(+), 120 deletions(-) create mode 100644 tests/test_visualize.py diff --git a/.gitignore b/.gitignore index d6cc219..36be7c6 100644 --- a/.gitignore +++ b/.gitignore @@ -84,6 +84,7 @@ docs/ PROJECTMEM_PLAN.md *PLAN*.md *plan*.md +.worktrees/ # Brand SVG sources only (PNGs and demo SVG stay public) brand/logo-mark.svg diff --git a/src/projectmem/commands/visualize.py b/src/projectmem/commands/visualize.py index 580859d..f9dd573 100644 --- a/src/projectmem/commands/visualize.py +++ b/src/projectmem/commands/visualize.py @@ -14,6 +14,11 @@ from projectmem.commands.score import calculate_score +DENSE_FILE_EVENT_THRESHOLD = 10 +FAILURE_IMPORTANCE_WEIGHT = 3 +ROOT_DIRECTORY_BUCKET = "./" + + def run( root: Path | None = None, output: Path | None = None, @@ -23,7 +28,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) @@ -66,6 +72,77 @@ def run( if open_browser: 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 _file_path_for_graph(path: str) -> str | None: + normalized = path.replace("\\", "/") + while normalized.startswith("./"): + normalized = normalized[2:] + normalized = normalized.strip("/") + return normalized or None + + +def _file_graph_metadata( + path: str, + event_count: int = 0, + failure_count: int = 0, +) -> dict[str, Any]: + normalized = path.replace("\\", "/").strip("/") + parts = [part for part in normalized.split("/") if part] + directory_parts = parts[:-1] + top_directory = f"{directory_parts[0]}/" if directory_parts else ROOT_DIRECTORY_BUCKET + importance = event_count + failure_count * FAILURE_IMPORTANCE_WEIGHT + return { + "path": normalized, + "directory_parts": directory_parts, + "top_directory": top_directory, + "event_count": event_count, + "failure_count": failure_count, + "failures": failure_count, + "importance": importance, + "dense_event_threshold": DENSE_FILE_EVENT_THRESHOLD, + "is_dense": event_count >= DENSE_FILE_EVENT_THRESHOLD, + } + def build_project_map_graph(map_text: str) -> dict[str, Any]: nodes = [] @@ -144,15 +221,19 @@ 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 = [] # Track nodes to avoid duplicates node_ids = set() - # Failure counts for heatmap - failure_counts = {} + # Counts for heatmap, labels, and collapse decisions + event_counts: dict[str, int] = {} + failure_counts: dict[str, int] = {} # Helper to add file nodes def add_file(path: str): @@ -163,23 +244,38 @@ def add_file(path: str): "type": "file", "label": path.split("/")[-1], "full_path": path, - "failures": 0 + **_file_graph_metadata(path), }) - # First pass: Collect all files and calculate failures + # First pass: Collect all files and calculate counts 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) + explicit_files = [ + normalized_file + for file_path in event.files + if (normalized_file := _file_path_for_graph(file_path)) + ] + linked_files = list(dict.fromkeys(explicit_files)) + location_file = _location_path_for_graph(event.location, root=root) + if location_file and location_file not in linked_files: + linked_files.append(location_file) + + for file_path in linked_files: + add_file(file_path) + event_counts[file_path] = event_counts.get(file_path, 0) + 1 if event.outcome == "failed": - failure_counts[f] = failure_counts.get(f, 0) + 1 + failure_counts[file_path] = failure_counts.get(file_path, 0) + 1 - # Update failure counts in nodes + # Update file metadata in nodes for node in nodes: - if node["id"] in failure_counts: - node["failures"] = failure_counts[node["id"]] + if node["type"] != "file": + continue + node.update( + _file_graph_metadata( + node["id"], + event_count=event_counts.get(node["id"], 0), + failure_count=failure_counts.get(node["id"], 0), + ) + ) # Second pass: Collect events and links for i, event in enumerate(events): @@ -201,14 +297,21 @@ def add_file(path: str): node_data["capture_source"] = event.capture_source nodes.append(node_data) - # Link event to its files - for f in event.files: - links.append({"source": event_id, "target": f, "type": "mention"}) + explicit_files = [ + normalized_file + for file_path in event.files + if (normalized_file := _file_path_for_graph(file_path)) + ] + linked_files = list(dict.fromkeys(explicit_files)) - # 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"}) + # Link event to its explicit files + for file_path in linked_files: + links.append({"source": event_id, "target": file_path, "type": "mention"}) + + # Link event to its location file when explicit files do not already do so + location_file = _location_path_for_graph(event.location, root=root) + if location_file and location_file not in linked_files: + links.append({"source": event_id, "target": location_file, "type": "at"}) # 3. Calculate ROI stats raw_events = [e.__dict__ for e in events] @@ -412,6 +515,54 @@ def add_file(path: str): .story-link { stroke-opacity:0.35; stroke-width:1px; } .story-node { cursor:pointer; transition: filter 0.2s; } .story-node:hover { filter: brightness(1.3); } + .story-controls { + position:absolute; top:16px; left:16px; z-index:6; + display:flex; flex-wrap:wrap; align-items:center; gap:8px; + max-width:calc(100% - 260px); + } + .story-control-btn { + border:1px solid var(--border); + background:rgba(255,255,255,0.94); + color:var(--text-dim); + border-radius:8px; + padding:7px 10px; + font-size:11px; + font-weight:700; + cursor:pointer; + box-shadow:0 4px 14px rgba(11,42,74,0.08); + } + .story-control-btn:hover { color:var(--text); border-color:var(--border-light); } + .story-control-btn.active { + background:var(--primary-glow); + color:var(--primary); + border-color:rgba(31,111,235,0.32); + } + .story-label { + pointer-events:none; + font-size:10px; + fill:#475569; + paint-order:stroke; + stroke:rgba(255,255,255,0.9); + stroke-width:3px; + stroke-linejoin:round; + } + .story-node.dimmed, + .story-link.dimmed, + .story-label.dimmed, + .story-bubble-label.dimmed { opacity:0.14; } + .story-node.focused, + .story-link.focused, + .story-label.focused, + .story-bubble-label.focused { opacity:1; } + .story-bubble-label { + pointer-events:none; + font-size:11px; + font-weight:700; + fill:#1e3a5f; + paint-order:stroke; + stroke:rgba(255,255,255,0.94); + stroke-width:4px; + } /* ═══ ROI Dashboard ═══ */ .roi-scroll { overflow-y:auto; height:100%; padding:28px 24px; } @@ -809,6 +960,12 @@ def add_file(path: str):
+
+ + + + +
Issue / Event
@@ -816,7 +973,9 @@ def add_file(path: str):
Failed Attempt
File (warm = more failures)
Decision
-
Scroll to zoom · Drag to pan
+
Collapsed file
+
Directory bubble
+
Scroll to zoom · Drag to pan · Click files to focus
@@ -1058,110 +1217,456 @@ def add_file(path: str): })(); // ══════════════════════════════════════════ - // TAB 1: Story Map — Filter noise, add glow + // TAB 1: Story Map — View-state graph // ══════════════════════════════════════════ - const NOISE = /__pycache__|\\.pyc$|\\.DS_Store|\\.egg-info/; - const cleanNodes = data.nodes.filter(n => !NOISE.test(n.id)); - const cleanNodeIds = new Set(cleanNodes.map(n => n.id)); - const cleanLinks = data.links.filter(l => { - const s = typeof l.source === 'object' ? l.source.id : l.source; - const t = typeof l.target === 'object' ? l.target.id : l.target; - return cleanNodeIds.has(s) && cleanNodeIds.has(t); - }); + (function renderStoryMap() { + const NOISE = /__pycache__|\\.pyc$|\\.DS_Store|\\.egg-info/; + const DENSE_FILE_EVENT_THRESHOLD = 10; + const ROOT_DIRECTORY_BUCKET = './'; + + const canonicalNodes = data.nodes.filter(n => !NOISE.test(n.id)); + const canonicalNodeIds = new Set(canonicalNodes.map(n => n.id)); + const canonicalLinks = data.links.filter(l => { + const s = typeof l.source === 'object' ? l.source.id : l.source; + const t = typeof l.target === 'object' ? l.target.id : l.target; + return canonicalNodeIds.has(s) && canonicalNodeIds.has(t); + }); - const svg = d3.select("#canvas"); - const width = window.innerWidth; - const height = window.innerHeight - 94; - const g = svg.append("g"); - - // Glow filter - const defs = svg.append("defs"); - const glow = defs.append("filter").attr("id","glow"); - glow.append("feGaussianBlur").attr("stdDeviation","3").attr("result","blur"); - glow.append("feMerge").selectAll("feMergeNode") - .data(["blur","SourceGraphic"]).enter() - .append("feMergeNode").attr("in", d=>d); - - svg.call(d3.zoom().scaleExtent([0.3,5]).on("zoom", e => g.attr("transform", e.transform))); - - const sim = d3.forceSimulation(cleanNodes) - .force("link", d3.forceLink(cleanLinks).id(d=>d.id).distance(d => { - const s = typeof d.source === 'object' ? d.source : cleanNodes.find(n=>n.id===d.source); - return (s && s.type === 'event') ? 60 : 90; - })) - .force("charge", d3.forceManyBody().strength(-250)) - .force("center", d3.forceCenter(width/2, height/2)) - .force("collision", d3.forceCollide(12)); - - const link = g.append("g").selectAll("line") - .data(cleanLinks).enter().append("line") - .attr("class","story-link") - .attr("stroke", d => { - const s = typeof d.source === 'object' ? d.source : cleanNodes.find(n=>n.id===d.source); - if (!s) return '#334155'; - if (s.outcome === 'failed') return '#ef444480'; - if (s.event_type === 'fix') return '#10b98160'; - return '#33415580'; + const state = { + fileCollapse: false, + directoryCollapse: false, + expandedDirectories: new Set(), + expandedFiles: new Set(), + focusedFileId: null, + selectedNodeId: null, + previousFileCollapse: false, + }; + + const byId = new Map(canonicalNodes.map(n => [n.id, n])); + const linksByFile = new Map(); + canonicalLinks.forEach(link => { + const s = sourceId(link); + const t = targetId(link); + const target = byId.get(t); + if (target && target.type === 'file') { + if (!linksByFile.has(t)) linksByFile.set(t, []); + linksByFile.get(t).push({ ...link, source: s, target: t }); + } }); - function nodeColor(d) { - if (d.type === 'file') { - const heat = Math.min((d.failures||0)/5, 1); - return d3.interpolate("#475569","#f87171")(heat); + const svg = d3.select("#canvas"); + const width = window.innerWidth; + const height = window.innerHeight - 94; + const g = svg.append("g"); + let sim = null; + + const defs = svg.append("defs"); + const glow = defs.append("filter").attr("id","glow"); + glow.append("feGaussianBlur").attr("stdDeviation","3").attr("result","blur"); + glow.append("feMerge").selectAll("feMergeNode") + .data(["blur","SourceGraphic"]).enter() + .append("feMergeNode").attr("in", d=>d); + + svg.call(d3.zoom().scaleExtent([0.3,5]).on("zoom", e => g.attr("transform", e.transform))); + + function sourceId(link) { return typeof link.source === 'object' ? link.source.id : link.source; } + function targetId(link) { return typeof link.target === 'object' ? link.target.id : link.target; } + function isDenseFile(node) { return node.type === 'file' && (node.event_count || 0) >= DENSE_FILE_EVENT_THRESHOLD; } + function fileLabel(node) { return node.label || (node.id || '').split('/').pop(); } + + function fullGraph() { + return { + nodes: canonicalNodes.map(n => ({ ...n })), + links: canonicalLinks.map(l => ({ ...l, source: sourceId(l), target: targetId(l) })), + }; } - if (d.event_type==='fix') return '#10b981'; - if (d.outcome==='failed') return '#ef4444'; - if (d.event_type==='decision') return '#818cf8'; - return '#3b82f6'; - } - function nodeRadius(d) { - if (d.type==='event') { - if (d.event_type==='fix' || d.outcome==='failed') return 8; - return 6; + + function makeFileBubble(fileNode) { + return { + ...fileNode, + id: 'file-bubble:' + fileNode.id, + file_id: fileNode.id, + type: 'file', + synthetic_type: 'file_bubble', + label: fileLabel(fileNode), + display_label: fileLabel(fileNode) + ' · ' + (fileNode.event_count || 0) + ' events', + event_count: fileNode.event_count || 0, + failure_count: fileNode.failure_count || fileNode.failures || 0, + importance: fileNode.importance || 0, + }; } - return 5 + Math.min((d.failures||0), 4); - } - const node = g.append("g").selectAll("circle") - .data(cleanNodes).enter().append("circle") - .attr("class","story-node") - .attr("r", nodeRadius) - .attr("fill", d => d.auto_captured ? nodeColor(d)+'99' : nodeColor(d)) - .attr("stroke", d => d.type==='event' ? nodeColor(d) : '#0f172a') - .attr("stroke-width", d => d.type==='event' ? 2 : 1) - .attr("stroke-opacity", d => d.type==='event' ? 0.3 : 1) - .attr("stroke-dasharray", d => d.auto_captured ? "3,2" : null) - .attr("filter", d => (d.type==='event' && (d.event_type==='fix' || d.outcome==='failed')) ? "url(#glow)" : null) - .call(d3.drag() - .on("start", e => { if(!e.active) sim.alphaTarget(0.3).restart(); e.subject.fx=e.subject.x; e.subject.fy=e.subject.y; }) - .on("drag", e => { e.subject.fx=e.x; e.subject.fy=e.y; }) - .on("end", e => { if(!e.active) sim.alphaTarget(0); e.subject.fx=null; e.subject.fy=null; })); - - const labels = g.append("g").selectAll("text") - .data(cleanNodes.filter(d => d.type==='event' || (d.failures||0)>0)) - .enter().append("text") - .attr("font-size","10px") - .attr("fill", d => d.type==='event' ? '#5A6B82' : '#475569') - .attr("dx",12).attr("dy",".35em") - .text(d => d.label); - - node.on("mouseover", (event,d) => { - const tt = document.getElementById("tooltip"); - tt.style.opacity=1; - const typeLabel = d.event_type ? d.event_type.toUpperCase() : (d.type||'').toUpperCase(); - const details = d.summary || d.full_path || ''; - const outcome = d.outcome ? '
Outcome: '+d.outcome+'' : ''; - const loc = d.location ? '
@ '+d.location+'' : ''; - tt.innerHTML = ''+typeLabel+': '+d.label+'
'+details+outcome+loc; - tt.style.left=(event.pageX+14)+"px"; - tt.style.top=(event.pageY-14)+"px"; - }).on("mouseout", () => { document.getElementById("tooltip").style.opacity=0; }); - - sim.on("tick", () => { - link.attr("x1",d=>d.source.x).attr("y1",d=>d.source.y).attr("x2",d=>d.target.x).attr("y2",d=>d.target.y); - node.attr("cx",d=>d.x).attr("cy",d=>d.y); - labels.attr("x",d=>d.x).attr("y",d=>d.y); - }); + function directoryPathForParts(parts) { + if (!parts || !parts.length) return ROOT_DIRECTORY_BUCKET; + return parts.join('/') + '/'; + } + + function childDirectoryPath(fileNode) { + const parts = fileNode.directory_parts || []; + for (let depth = parts.length; depth >= 0; depth--) { + const parent = directoryPathForParts(parts.slice(0, depth)); + if (state.expandedDirectories.has(parent)) { + if (depth >= parts.length) return fileNode.id; + return directoryPathForParts(parts.slice(0, depth + 1)); + } + } + return fileNode.top_directory || ROOT_DIRECTORY_BUCKET; + } + + function makeDirectoryBubble(directoryPath, children) { + const eventCount = children.reduce((sum, child) => sum + (child.event_count || 0), 0); + const failureCount = children.reduce((sum, child) => sum + (child.failure_count || child.failures || 0), 0); + return { + id: 'dir-bubble:' + directoryPath, + type: 'directory', + synthetic_type: 'directory_bubble', + directory_path: directoryPath, + label: directoryPath, + display_label: directoryPath + ' · ' + eventCount + ' events', + event_count: eventCount, + failure_count: failureCount, + importance: eventCount + failureCount * 3, + }; + } + + function deriveVisibleGraph() { + if (state.directoryCollapse) return deriveDirectoryGraph(); + if (state.fileCollapse) return deriveFileCollapsedGraph(); + return fullGraph(); + } + + function deriveFileCollapsedGraph() { + const visibleNodes = []; + const visibleLinks = []; + const hiddenEventIds = new Set(); + const replacementByFile = new Map(); + const linksByEvent = new Map(); + + canonicalNodes.forEach(node => { + if (node.type === 'file' && isDenseFile(node) && !state.expandedFiles.has(node.id)) { + const bubble = makeFileBubble(node); + visibleNodes.push(bubble); + replacementByFile.set(node.id, bubble.id); + return; + } + visibleNodes.push({ ...node }); + }); + + canonicalLinks.forEach(link => { + const s = sourceId(link); + const t = targetId(link); + const normalized = { ...link, source: s, target: t }; + if (!linksByEvent.has(s)) linksByEvent.set(s, []); + linksByEvent.get(s).push(normalized); + }); + + linksByEvent.forEach((eventLinks, eventId) => { + const fileLinks = eventLinks.filter(link => { + const target = byId.get(link.target); + return target && target.type === 'file'; + }); + if (fileLinks.length && fileLinks.every(link => replacementByFile.has(link.target))) { + hiddenEventIds.add(eventId); + } + }); + + const emittedPairs = new Set(); + canonicalLinks.forEach(link => { + const s = sourceId(link); + const t = targetId(link); + if (hiddenEventIds.has(s)) return; + const rewrittenTarget = replacementByFile.get(t) || t; + const pairKey = s + '\u0000' + rewrittenTarget; + if (emittedPairs.has(pairKey)) return; + emittedPairs.add(pairKey); + visibleLinks.push({ + ...link, + source: s, + target: rewrittenTarget, + }); + }); + + return { + nodes: visibleNodes.filter(node => !(node.type === 'event' && hiddenEventIds.has(node.id))), + links: visibleLinks, + }; + } + + function deriveDirectoryGraph() { + const fileNodes = canonicalNodes.filter(n => n.type === 'file'); + const eventNodesById = new Map(canonicalNodes.filter(n => n.type === 'event').map(n => [n.id, n])); + const groupChildren = new Map(); + const filePassthrough = new Set(); + + fileNodes.forEach(fileNode => { + const childPath = childDirectoryPath(fileNode); + if (childPath === fileNode.id) { + filePassthrough.add(fileNode.id); + return; + } + if (!groupChildren.has(childPath)) groupChildren.set(childPath, []); + groupChildren.get(childPath).push(fileNode); + }); + + const directoryNodes = [...groupChildren.entries()].map(([directoryPath, children]) => + makeDirectoryBubble(directoryPath, children) + ); + const replacementByFile = new Map(); + groupChildren.forEach((children, directoryPath) => { + children.forEach(fileNode => replacementByFile.set(fileNode.id, 'dir-bubble:' + directoryPath)); + }); + + const linksByEvent = new Map(); + canonicalLinks.forEach(link => { + const s = sourceId(link); + const t = targetId(link); + const target = byId.get(t); + if (!target || target.type !== 'file') return; + if (!linksByEvent.has(s)) linksByEvent.set(s, []); + linksByEvent.get(s).push({ ...link, source: s, target: t }); + }); + + const visibleEventIds = new Set(); + const visibleLinks = []; + const emittedPairs = new Set(); + + linksByEvent.forEach((eventLinks, eventId) => { + const visibleTargets = [...new Set(eventLinks.map(link => replacementByFile.get(link.target) || link.target))]; + if (visibleTargets.length <= 1 && visibleTargets[0] && visibleTargets[0].startsWith('dir-bubble:')) { + return; + } + if (visibleTargets.length === 0) return; + visibleEventIds.add(eventId); + visibleTargets.forEach(target => { + const pairKey = eventId + '\u0000' + target; + if (emittedPairs.has(pairKey)) return; + emittedPairs.add(pairKey); + visibleLinks.push({ source: eventId, target: target }); + }); + }); + + const visibleNodes = [ + ...directoryNodes, + ...fileNodes.filter(n => filePassthrough.has(n.id)).map(n => ({ ...n })), + ...[...visibleEventIds] + .map(id => eventNodesById.get(id)) + .filter(Boolean) + .map(n => ({ ...n })), + ]; + + return { nodes: visibleNodes, links: visibleLinks }; + } + + function restart() { + const visible = deriveVisibleGraph(); + drawVisibleGraph(visible.nodes, visible.links); + updateButtons(); + } + + function updateButtons() { + document.getElementById('story-file-collapse').classList.toggle('active', state.fileCollapse); + document.getElementById('story-directory-collapse').classList.toggle('active', state.directoryCollapse); + } + + document.getElementById('story-file-collapse').addEventListener('click', () => { + if (state.directoryCollapse) return; + state.fileCollapse = !state.fileCollapse; + restart(); + }); + document.getElementById('story-directory-collapse').addEventListener('click', () => { + if (!state.directoryCollapse) { + state.previousFileCollapse = state.fileCollapse; + state.directoryCollapse = true; + state.fileCollapse = false; + } else { + state.directoryCollapse = false; + state.fileCollapse = state.previousFileCollapse; + state.expandedDirectories.clear(); + } + restart(); + }); + document.getElementById('story-expand-all').addEventListener('click', () => { + state.fileCollapse = false; + state.directoryCollapse = false; + state.expandedDirectories.clear(); + state.expandedFiles.clear(); + state.focusedFileId = null; + state.selectedNodeId = null; + restart(); + }); + document.getElementById('story-reset-focus').addEventListener('click', () => { + state.focusedFileId = null; + state.selectedNodeId = null; + restart(); + }); + + function nodeFill(d) { + if (d.synthetic_type === 'directory_bubble') return '#ecfeff'; + if (d.synthetic_type === 'file_bubble') return '#dbeafe'; + if (d.type === 'file') { + const heat = Math.min((d.failure_count || d.failures || 0) / 5, 1); + return d3.interpolate("#334155","#f87171")(heat); + } + if (d.event_type === 'fix') return '#10b981'; + if (d.outcome === 'failed') return '#ef4444'; + if (d.event_type === 'decision') return '#818cf8'; + if (d.event_type === 'note') return '#64748b'; + return '#3b82f6'; + } + + function nodeStroke(d) { + if (d.synthetic_type === 'directory_bubble') return '#0891b2'; + if (d.synthetic_type === 'file_bubble') return '#2563eb'; + return d.type === 'event' ? nodeFill(d) : '#0f172a'; + } + + function nodeRadius(d) { + if (d.synthetic_type === 'directory_bubble') return 18 + Math.min(Math.sqrt(d.event_count || 1) * 3, 28); + if (d.synthetic_type === 'file_bubble') return 15 + Math.min(Math.sqrt(d.event_count || 1) * 2.5, 22); + if (d.type === 'event') { + if (d.auto_captured) return 4.5; + if (d.event_type === 'fix' || d.outcome === 'failed') return 8; + return 6; + } + return 7 + Math.min((d.failure_count || d.failures || 0), 5); + } + + function linkColor(d) { + const source = byId.get(sourceId(d)); + if (source && source.outcome === 'failed') return '#ef444480'; + if (source && source.event_type === 'fix') return '#10b98160'; + return '#33415580'; + } + + function linkDistance(d) { + const source = byId.get(sourceId(d)); + const target = byId.get(targetId(d)); + if ((source && source.synthetic_type) || (target && target.synthetic_type)) return 120; + if (source && source.type === 'event') return 86; + return 110; + } + + function shouldShowLabel(d) { + if (d.synthetic_type) return true; + if (d.id === state.selectedNodeId || d.id === state.focusedFileId) return true; + if (state.focusedFileId && isAttachedToFocusedFile(d)) return true; + if (d.type === 'file' && (d.importance || 0) >= 10) return true; + if (d.type === 'event' && (d.outcome === 'failed' || d.event_type === 'fix')) return true; + return false; + } + + function isAttachedToFocusedFile(d) { + if (!state.focusedFileId) return false; + if (d.id === state.focusedFileId || d.file_id === state.focusedFileId) return true; + const focusedLinks = linksByFile.get(state.focusedFileId) || []; + return focusedLinks.some(link => sourceId(link) === d.id || targetId(link) === d.id); + } + + function focusClassForNode(d) { + if (state.directoryCollapse) return ""; + if (!state.focusedFileId) return ""; + return isAttachedToFocusedFile(d) ? "focused" : "dimmed"; + } + + function focusClassForLink(d) { + if (state.directoryCollapse) return ""; + if (!state.focusedFileId) return ""; + const s = sourceId(d); + const t = targetId(d); + const focusedBubbleId = 'file-bubble:' + state.focusedFileId; + return s === state.focusedFileId || t === state.focusedFileId || s === focusedBubbleId || t === focusedBubbleId ? "focused" : "dimmed"; + } + + function showStoryTooltip(event, d) { + const tt = document.getElementById("tooltip"); + tt.style.opacity = 1; + const typeLabel = d.synthetic_type + ? (d.synthetic_type === 'directory_bubble' ? 'DIRECTORY' : 'FILE') + : (d.event_type ? d.event_type.toUpperCase() : (d.type || '').toUpperCase()); + const details = d.summary || d.full_path || d.path || d.id || ''; + const count = d.event_count ? '
'+d.event_count+' attached events' : ''; + const failures = d.failure_count ? '
'+d.failure_count+' failed attempts' : ''; + const outcome = d.outcome ? '
Outcome: '+d.outcome+'' : ''; + const loc = d.location ? '
@ '+d.location+'' : ''; + tt.innerHTML = ''+typeLabel+': '+(d.display_label || d.label || d.id)+'
'+details+count+failures+outcome+loc; + tt.style.left = (event.pageX + 14) + "px"; + tt.style.top = (event.pageY - 14) + "px"; + } + + function handleNodeClick(d) { + state.selectedNodeId = d.id; + if (d.synthetic_type === 'directory_bubble') { + state.expandedDirectories.add(d.directory_path); + restart(); + return; + } + if (d.synthetic_type === 'file_bubble') { + state.expandedFiles.add(d.file_id); + state.focusedFileId = d.file_id; + restart(); + return; + } + if (d.type === 'file') { + state.focusedFileId = d.id; + restart(); + return; + } + restart(); + } + + function drawVisibleGraph(nodes, links) { + if (sim) sim.stop(); + g.selectAll("*").remove(); + sim = d3.forceSimulation(nodes) + .force("link", d3.forceLink(links).id(d=>d.id).distance(linkDistance)) + .force("charge", d3.forceManyBody().strength(-250)) + .force("center", d3.forceCenter(width/2, height/2)) + .force("collision", d3.forceCollide(d => nodeRadius(d) + 3)); + drawLayers(nodes, links); + } + + function drawLayers(nodes, links) { + const link = g.append("g").selectAll("line") + .data(links).enter().append("line") + .attr("class", d => "story-link " + focusClassForLink(d)) + .attr("stroke", linkColor); + + const node = g.append("g").selectAll("circle") + .data(nodes).enter().append("circle") + .attr("class", d => "story-node " + focusClassForNode(d)) + .attr("r", nodeRadius) + .attr("fill", nodeFill) + .attr("stroke", nodeStroke) + .attr("stroke-width", d => d.synthetic_type ? 2 : (d.type === 'event' ? 2 : 1)) + .attr("stroke-opacity", d => d.type === 'event' ? 0.3 : 1) + .attr("stroke-dasharray", d => d.auto_captured ? "3,2" : null) + .attr("filter", d => (d.type === 'event' && (d.event_type === 'fix' || d.outcome === 'failed')) ? "url(#glow)" : null) + .call(d3.drag() + .on("start", e => { if(!e.active) sim.alphaTarget(0.3).restart(); e.subject.fx=e.subject.x; e.subject.fy=e.subject.y; }) + .on("drag", e => { e.subject.fx=e.x; e.subject.fy=e.y; }) + .on("end", e => { if(!e.active) sim.alphaTarget(0); e.subject.fx=null; e.subject.fy=null; })); + + node.on("click", (event, d) => handleNodeClick(d)); + node.on("mouseover", (event,d) => showStoryTooltip(event, d)); + node.on("mouseout", () => { document.getElementById("tooltip").style.opacity=0; }); + + const labels = g.append("g").selectAll("text") + .data(nodes.filter(shouldShowLabel)) + .enter().append("text") + .attr("class", d => (d.synthetic_type ? "story-bubble-label " : "story-label ") + focusClassForNode(d)) + .attr("dx", d => nodeRadius(d) + 6) + .attr("dy",".35em") + .text(d => d.display_label || d.label); + + sim.on("tick", () => { + link.attr("x1",d=>d.source.x).attr("y1",d=>d.source.y).attr("x2",d=>d.target.x).attr("y2",d=>d.target.y); + node.attr("cx",d=>d.x).attr("cy",d=>d.y); + labels.attr("x",d=>d.x).attr("y",d=>d.y); + }); + } + + restart(); + })(); // ══════════════════════════════════════════ // TAB 2: ROI Dashboard — Full overhaul diff --git a/tests/test_visualize.py b/tests/test_visualize.py new file mode 100644 index 0000000..5aaa1d4 --- /dev/null +++ b/tests/test_visualize.py @@ -0,0 +1,315 @@ +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 _node_by_id(graph: dict, node_id: str) -> dict: + return next(node for node in graph["nodes"] if node["id"] == node_id) + + +def _events_for_file(path: str, count: int, *, failed: int = 0) -> list[Event]: + events = [] + for index in range(count): + outcome = "failed" if index < failed else "worked" + events.append( + Event( + id=f"evt_{path.replace('/', '_').replace('.', '_')}_{index}", + type="attempt", + outcome=outcome, + files=[path], + summary=f"Attempt {index} for {path}", + ) + ) + return events + + +def test_build_graph_data_adds_file_directory_metadata(tmp_path: Path) -> None: + event = Event( + id="evt_metadata", + type="note", + files=["src/projectmem/commands/visualize.py"], + summary="Story Map needs directory metadata", + ) + + graph = build_graph_data([event], root=tmp_path) + + file_node = _node_by_id(graph, "src/projectmem/commands/visualize.py") + assert file_node["path"] == "src/projectmem/commands/visualize.py" + assert file_node["directory_parts"] == ["src", "projectmem", "commands"] + assert file_node["top_directory"] == "src/" + assert file_node["event_count"] == 1 + assert file_node["failure_count"] == 0 + assert file_node["importance"] == 1 + + +def test_build_graph_data_groups_root_level_files_under_root_bucket( + tmp_path: Path, +) -> None: + event = Event( + id="evt_root", + type="note", + files=["README.md"], + summary="Root files should have a stable directory bucket", + ) + + graph = build_graph_data([event], root=tmp_path) + + file_node = _node_by_id(graph, "README.md") + assert file_node["directory_parts"] == [] + assert file_node["top_directory"] == "./" + + +def test_build_graph_data_counts_all_attached_events_for_dense_files( + tmp_path: Path, +) -> None: + events = _events_for_file("src/projectmem/commands/visualize.py", 10) + + graph = build_graph_data(events, root=tmp_path) + + file_node = _node_by_id(graph, "src/projectmem/commands/visualize.py") + assert file_node["event_count"] == 10 + assert file_node["dense_event_threshold"] == 10 + assert file_node["is_dense"] is True + + +def test_build_graph_data_marks_nine_events_as_not_dense(tmp_path: Path) -> None: + events = _events_for_file("src/projectmem/commands/visualize.py", 9) + + graph = build_graph_data(events, root=tmp_path) + + file_node = _node_by_id(graph, "src/projectmem/commands/visualize.py") + assert file_node["event_count"] == 9 + assert file_node["is_dense"] is False + + +def test_build_graph_data_importance_includes_failure_weight( + tmp_path: Path, +) -> None: + events = _events_for_file("src/projectmem/mcp_server.py", 6, failed=4) + + graph = build_graph_data(events, root=tmp_path) + + file_node = _node_by_id(graph, "src/projectmem/mcp_server.py") + assert file_node["event_count"] == 6 + assert file_node["failure_count"] == 4 + assert file_node["importance"] == 18 + + +def test_build_graph_data_counts_same_file_and_location_once_per_event( + 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_duplicate_file_location", + type="attempt", + files=[r"src\projectmem\commands\visualize.py"], + location="src/projectmem/commands/visualize.py:10", + outcome="failed", + summary="Auto-capture referenced the same file twice", + ) + + graph = build_graph_data([event], root=tmp_path) + + file_node_ids = [node["id"] for node in graph["nodes"] if node["type"] == "file"] + assert file_node_ids == ["src/projectmem/commands/visualize.py"] + file_node = _node_by_id(graph, "src/projectmem/commands/visualize.py") + assert file_node["event_count"] == 1 + assert file_node["failure_count"] == 1 + assert file_node["failures"] == 1 + assert file_node["importance"] == 4 + + +def test_build_graph_data_deduplicates_duplicate_file_links( + 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_duplicate_links", + type="attempt", + files=[ + "src/projectmem/commands/visualize.py", + r".\src\projectmem\commands\visualize.py", + ], + location="src/projectmem/commands/visualize.py:10", + outcome="failed", + summary="Auto-capture produced duplicate file links", + ) + + graph = build_graph_data([event], root=tmp_path) + + matching_links = [ + link + for link in graph["links"] + if link["source"] == "evt_duplicate_links" + and link["target"] == "src/projectmem/commands/visualize.py" + ] + assert matching_links == [ + { + "source": "evt_duplicate_links", + "target": "src/projectmem/commands/visualize.py", + "type": "mention", + } + ] + + +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)