diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py index af8eb26..bbdac9e 100644 --- a/src/docforge/visualization.py +++ b/src/docforge/visualization.py @@ -33,9 +33,10 @@ from .errors import DocForgeError from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize from .project import project_root_fingerprint -VISUALIZATION_TEMPLATE = "graph-browser@10" +VISUALIZATION_TEMPLATE = "graph-browser@11" DEFAULT_EDGE_LIMIT = 100 MAX_EDGE_LIMIT = 400 +MAX_LINEAGE_EDGE_LIMIT = 1_000 DEFAULT_INITIAL_GRACE_SECONDS = 120.0 DEFAULT_LEASE_SECONDS = 180.0 LEASE_MONITOR_INTERVAL_SECONDS = 1.0 @@ -287,6 +288,76 @@ class VisualizationIndexSnapshot: snapshot=True, ) + def lineage(self, node_id: str, *, limit: int) -> dict[str, object]: + """Return every bounded, directed ancestry path terminating at ``node_id``. + + A lineage follows stored edge direction only: ``source -> target``. This keeps + Flow literal and auditable. It does not reinterpret relationship meanings or + reverse dependency/data edges as the old client-side Flow view did. + """ + if type(limit) is not int or limit < 1 or limit > MAX_LINEAGE_EDGE_LIMIT: + raise DocForgeError( + "invalid_limit", + "Visualization lineage limit exceeds the fixed safety boundary", + maximum=MAX_LINEAGE_EDGE_LIMIT, + ) + with self._connection() as connection: + root_row = connection.execute( + "SELECT * FROM nodes WHERE node_id = ?", (node_id,) + ).fetchone() + if root_row is None: + raise DocForgeError( + "missing_node", + "No node has the requested stable ID", + node_id=node_id, + ) + visited = {node_id} + frontier = {node_id} + selected: list[dict[str, str]] = [] + truncated = False + while frontier and len(selected) < limit: + placeholders = ",".join("?" for _ in frontier) + remaining = limit - len(selected) + rows = connection.execute( + "SELECT source_id, relation, target_id FROM edges " + f"WHERE target_id IN ({placeholders}) " + "ORDER BY source_id, relation, target_id LIMIT ?", + (*sorted(frontier), remaining + 1), + ).fetchall() + if len(rows) > remaining: + rows = rows[:remaining] + truncated = True + next_frontier: set[str] = set() + for row in rows: + edge = { + "source_id": row["source_id"], + "relation": row["relation"], + "target_id": row["target_id"], + } + selected.append(edge) + source_id = edge["source_id"] + if source_id not in visited: + visited.add(source_id) + next_frontier.add(source_id) + frontier = next_frontier + if frontier and len(selected) >= limit: + truncated = True + placeholders = ",".join("?" for _ in visited) + node_rows = connection.execute( + f"SELECT * FROM nodes WHERE node_id IN ({placeholders}) ORDER BY node_id", + tuple(sorted(visited)), + ).fetchall() + return self._result( + root=node_id, + lineage=True, + edge_limit=limit, + truncated=truncated, + node=_node_dict(root_row), + nodes=[_node_dict(row, include_content=False) for row in node_rows], + edges=selected, + snapshot=True, + ) + def require_node(self, node_id: str) -> None: with self._connection() as connection: row = connection.execute("SELECT 1 FROM nodes WHERE node_id = ?", (node_id,)).fetchone() @@ -615,6 +686,9 @@ class VisualizationRunner: elif parsed.path == f"{prefix}/api/node": self._touch_lease() payload = self._node(reader, params) + elif parsed.path == f"{prefix}/api/lineage": + self._touch_lease() + payload = self._lineage(reader, params) else: self._respond_error( handler, @@ -682,6 +756,23 @@ class VisualizationRunner: ) return reader.node(node_id, depth=depth, limit=limit) + def _lineage( + self, + reader: VisualizationIndexSnapshot, + params: dict[str, list[str]], + ) -> dict[str, object]: + node_id = _one(params, "id").strip() + if not node_id: + raise DocForgeError("missing_node", "One exact node ID is required") + limit = _integer(_one(params, "limit") or str(MAX_LINEAGE_EDGE_LIMIT)) + if limit > MAX_LINEAGE_EDGE_LIMIT: + raise DocForgeError( + "invalid_limit", + "Visualization lineage limit exceeds the fixed safety boundary", + maximum=MAX_LINEAGE_EDGE_LIMIT, + ) + return reader.lineage(node_id, limit=limit) + def _filter( self, reader: VisualizationIndexSnapshot, @@ -2046,32 +2137,18 @@ _GRAPH_BROWSER_HTML = r""" }, ])); } - function semanticFlowEdge(edge) { - const direction = relationStyle(edge.relation).flow; - if (direction === null) return null; - if (direction === "reverse") { - return { - ...edge, - source_id: edge.target_id, - target_id: edge.source_id, - stored_source_id: edge.source_id, - stored_target_id: edge.target_id, - }; - } - return { - ...edge, - stored_source_id: edge.source_id, - stored_target_id: edge.target_id, - }; - } function buildFlowGraph(data) { - const semanticEdges = data.edges.map(semanticFlowEdge).filter(Boolean); + // The server has already supplied the complete directed ancestry for this + // focus. Keep every stored edge as-is: source -> target. Flow must show + // what literally leads to the selected terminal, not infer an alternate + // direction from a relationship label. + const lineageEdges = data.edges; const upstreamHops = new Map([[data.root, 0]]); let frontier = [data.root]; while (frontier.length) { const next = []; for (const targetId of frontier) { - for (const edge of semanticEdges) { + for (const edge of lineageEdges) { if (edge.target_id !== targetId || upstreamHops.has(edge.source_id)) continue; upstreamHops.set(edge.source_id, upstreamHops.get(targetId) + 1); next.push(edge.source_id); @@ -2081,7 +2158,7 @@ _GRAPH_BROWSER_HTML = r""" } const nodes = data.nodes.filter((node) => upstreamHops.has(node.node_id)); const nodeIds = new Set(nodes.map((node) => node.node_id)); - const edges = semanticEdges.filter( + const edges = lineageEdges.filter( (edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id), ); const topology = new Map(nodes.map((node) => [ @@ -2532,11 +2609,18 @@ _GRAPH_BROWSER_HTML = r""" } async function loadNode(nodeId) { try { - setStatus(`Loading ${nodeId}…`); - const params = new URLSearchParams({id: nodeId, depth: String(state.depth), limit: "100"}); - const data = await api(`node?${params}`); + const showingFlow = state.mode === "flow"; + setStatus(`${showingFlow ? "Tracing lineage for" : "Loading"} ${nodeId}…`); + const params = new URLSearchParams( + showingFlow + ? {id: nodeId, limit: "1000"} + : {id: nodeId, depth: String(state.depth), limit: "100"}, + ); + const data = await api(`${showingFlow ? "lineage" : "node"}?${params}`); renderGraph(data); - setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in neighborhood`); + const scope = showingFlow ? "directed ancestry" : "neighborhood"; + const suffix = data.truncated ? " · truncated at the safety limit" : ""; + setStatus(`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`); history.replaceState( null, "", @@ -2546,22 +2630,13 @@ _GRAPH_BROWSER_HTML = r""" setStatus(error.message, true); } } - function setViewMode(mode) { + async function setViewMode(mode) { if (mode !== "nodes" && mode !== "flow") return; state.mode = mode; $("view-switch").dataset.mode = mode; $("view-nodes").setAttribute("aria-pressed", String(mode === "nodes")); $("view-flow").setAttribute("aria-pressed", String(mode === "flow")); - if (state.graph) { - renderGraph(state.graph, true); - const suffix = mode === "flow" ? "upstream flow" : "node neighborhood"; - setStatus(`Showing ${suffix} for ${state.root}`); - history.replaceState( - null, - "", - `?node=${encodeURIComponent(state.root)}&depth=${state.depth}&view=${state.mode}`, - ); - } + if (state.root) await loadNode(state.root); } function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d4cafc6..16db175 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -130,7 +130,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): visualization = results[11].structuredContent["visualization"] self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["project_bound"]) - self.assertEqual("graph-browser@10", visualization["template"]) + self.assertEqual("graph-browser@11", visualization["template"]) self.assertEqual("managed_idle", visualization["lifetime"]["policy"]) self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"]) self.assertTrue(visualization["url"].startswith("http://127.0.0.1:")) diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 328fde3..8817f64 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -177,47 +177,34 @@ if (centered.width !== 500 || centered.height !== 300) fail("center preserves zo if (centered.x !== positions.get("child-two").x - 250) fail("center x"); if (centered.y !== positions.get("child-two").y - 150) fail("center y"); if (relationStyle("calls").family !== "Execution") fail("calls family"); -if (relationStyle("contains").flow !== "forward") fail("contains flow direction"); -if (relationStyle("reads").flow !== "reverse") fail("reads flow direction"); -if (relationStyle("documents").flow !== null) fail("documents excluded from flow"); if (relationStyle("unknown_relation").family !== "Other") fail("fallback relation family"); const flowData = { root: "primary", - depth: 2, nodes: [ {node_id: "primary"}, - {node_id: "caller"}, - {node_id: "dependency"}, - {node_id: "reader"}, + {node_id: "package"}, + {node_id: "module"}, {node_id: "test-class"}, {node_id: "test-file"}, - {node_id: "document"}, + {node_id: "evidence"}, ], edges: [ - {source_id: "caller", relation: "calls", target_id: "primary"}, - {source_id: "primary", relation: "depends_on", target_id: "dependency"}, - {source_id: "primary", relation: "reads", target_id: "reader"}, {source_id: "test-class", relation: "contains", target_id: "primary"}, {source_id: "test-file", relation: "contains", target_id: "test-class"}, - {source_id: "document", relation: "documents", target_id: "primary"}, + {source_id: "module", relation: "contains", target_id: "test-file"}, + {source_id: "package", relation: "contains", target_id: "module"}, + {source_id: "evidence", relation: "verifies", target_id: "primary"}, ], }; const flow = buildFlowGraph(flowData); const flowIds = new Set(flow.nodes.map((node) => node.node_id)); -if (!flowIds.has("caller") || !flowIds.has("dependency") || !flowIds.has("reader") - || !flowIds.has("test-class") || !flowIds.has("test-file")) { - fail("upstream flow membership"); -} -if (flowIds.has("document")) fail("evidence leaked into flow"); +if (!flowIds.has("package") || !flowIds.has("module") || !flowIds.has("test-class") + || !flowIds.has("test-file") || !flowIds.has("evidence")) fail("full lineage membership"); const flowPositions = layoutFlow(flow.nodes, flow.root, flow.topology); if (flowPositions.get("primary").x !== 0) fail("flow destination position"); -if (flowPositions.get("caller").x >= flowPositions.get("primary").x) { +if (flowPositions.get("package").x >= flowPositions.get("primary").x) { fail("flow upstream direction"); } -const dependencyEdge = flow.edges.find((edge) => edge.relation === "depends_on"); -if (dependencyEdge.source_id !== "dependency" || dependencyEdge.target_id !== "primary") { - fail("dependency semantic reversal"); -} const containmentEdge = flow.edges.find((edge) => edge.relation === "contains" && edge.source_id === "test-class"); if (!containmentEdge || containmentEdge.target_id !== "primary") { @@ -307,9 +294,9 @@ if (!containmentEdge || containmentEdge.target_id !== "primary") { self.assertIn("relationStyles", html) self.assertIn("appendRelationMarker", html) self.assertIn("renderRelationshipKey", html) - self.assertIn("semanticFlowEdge", html) self.assertIn("buildFlowGraph", html) self.assertIn("layoutFlow", html) + self.assertIn('api(`${showingFlow ? "lineage" : "node"}?${params}`)', html) self.assertIn("filterByDescriptor", html) self.assertIn("api(`filter?${params}`)", html) self.assertIn('setViewMode("flow")', html) @@ -371,6 +358,22 @@ if (!containmentEdge || containmentEdge.target_id !== "primary") { self.assertEqual("guide.workflow", node["node"]["node_id"]) self.assertEqual("guide.workflow", node["root"]) + lineage_query = urllib.parse.urlencode({"id": "guide.workflow", "limit": 1000}) + with urllib.request.urlopen( + f"{base}api/lineage?{lineage_query}", timeout=2 + ) as response: + lineage = json.load(response) + self.assertTrue(lineage["lineage"]) + self.assertEqual("guide.workflow", lineage["root"]) + self.assertIn( + { + "source_id": "proof.validation", + "relation": "proves", + "target_id": "guide.workflow", + }, + lineage["edges"], + ) + wrong_token = f"{first_url.scheme}://{first_url.netloc}/wrong-token/api/overview" with self.assertRaises(urllib.error.HTTPError) as missing: urllib.request.urlopen(wrong_token, timeout=2)