From 9b4258c8521e3e3c21dea3453d5120e1c3d48e01 Mon Sep 17 00:00:00 2001 From: Andraxion Date: Sat, 25 Jul 2026 21:08:43 -0400 Subject: [PATCH] Add function-scoped Logic visualization --- README.md | 14 +- SLICE_HISTORY.md | 22 ++ docs/CONTRACT.md | 26 +- docs/INCREMENTAL_INDEXING.md | 6 +- docs/MCP_CONTRACT.md | 12 +- docs/USER_MANUAL.md | 35 ++- pyproject.toml | 2 +- src/docforge/__init__.py | 2 +- src/docforge/adapter_contract.py | 5 + src/docforge/assets/graph.css | 3 +- src/docforge/assets/graph.html | 1 + src/docforge/assets/graph.js | 268 ++++++++++++++-- src/docforge/index.py | 199 +++++++++++- src/docforge/mcp_server.py | 10 +- src/docforge/models.py | 7 + src/docforge/python_logic.py | 516 +++++++++++++++++++++++++++++++ src/docforge/visualization.py | 188 ++++++++++- tests/test_adapter_contract.py | 11 + tests/test_mcp_server.py | 16 +- tests/test_python_logic.py | 126 ++++++++ tests/test_visualization.py | 11 + uv.lock | 2 +- 22 files changed, 1420 insertions(+), 62 deletions(-) create mode 100644 src/docforge/python_logic.py create mode 100644 tests/test_python_logic.py diff --git a/README.md b/README.md index b753c65..70ccec2 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ declared manuals, visualizes project structure, and manages reviewable documenta equivalence checks. - Keeps function-scoped control-flow projections separate from the primary architecture graph. - Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web, - source inspection, and branch-aware node hiding. + function-scoped Logic, source inspection, and branch-aware node hiding. - Supports generic documentation projects and project-owned source adapters. DocForge never treats indexed text as instructions. It does not run shell commands, mutate Git, @@ -35,7 +35,8 @@ implement the source manifest and extraction methods. Incremental adapters must ## Graph views -The browser presents the same indexed graph through three complementary views: +The browser presents the primary architecture graph through three complementary views and loads a +fourth function-scoped view only when requested: - **Nodes** shows a bounded, relation-neutral neighborhood around the focus. It is the broad inspection view for seeing stored incoming and outgoing relationships without changing their @@ -46,14 +47,19 @@ The browser presents the same indexed graph through three complementary views: inheritance, definitions, and tests flow toward the thing they help create or exercise. - **Web** shows the larger convergence picture: Flow contributors plus contextual relationships, callers, containers, and direct members or execution dependencies owned by the focus. +- **Logic** shows the possible static control paths inside a focused Python function or method. + Entry, decisions, actions, loops, merges, returns, and exceptions connect through explicit + `TRUE`, `FALSE`, `NEXT`, `CASE`, `LOOP`, `RETURN`, and `RAISE` paths. Logic is stored separately + and does not add statement-level noise to Nodes, Flow, Web, or search. Graph cards show the node's readable leaf name and kind without clipping either value. The full qualified identity remains available in the tooltip, compact descriptor, and full inspector. **Hide node** removes noise without changing the index. In Flow and Web, hiding a contributor also removes upstream ancestors that no longer have a path to the focus. Nodes between the hidden -contributor and the focus stay visible, and alternate ancestor paths remain intact. **Restore -hidden** restores the presentation. +contributor and the focus stay visible, and alternate ancestor paths remain intact. In Logic, +hiding a step inserts an explicit omitted-path bridge so downstream control flow remains readable. +**Restore hidden** restores the presentation. ## Five-minute start diff --git a/SLICE_HISTORY.md b/SLICE_HISTORY.md index e0d5a1d..cba89a9 100644 --- a/SLICE_HISTORY.md +++ b/SLICE_HISTORY.md @@ -1,5 +1,27 @@ # Completed slices +## Dev-Rewrite function-scoped Logic + +### Changed + +- Added a reusable Python AST control-flow analyzer for functions, methods, and nested functions. +- Added dedicated schema-2 SQLite tables for function-scoped logic owners, nodes, and edges without + placing statement-level data in primary graph search or traversal. +- Added the bounded `docforge_get_logic` read tool and a lazy Logic visualization tab. +- Added semantic Entry, Decision, Action, Control, Merge, and Terminal cards with explicit branch, + loop, exception, return, and raise paths. +- Added Logic-specific hiding that bridges retained predecessors and successors with an explicit + omitted path. +- Preserved Release 1 adapters and full projections. Adapters may emit no logic or opt in source by + source through the incremental extraction contract. + +### Verification + +- Tests cover Python branching, compound booleans, loops, `match`, exceptions, nested functions, + schema persistence, bounded reads, MCP registration, visualization APIs, and Logic UI assets. +- Strict Pyright, Ruff, formatting, compilation, warning-strict tests, web linting, package builds, + dependency audits, and browser QA pass. + ## Dev-Rewrite incremental compiler boundary ### Changed diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 48a9e9a..2d7db4e 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -18,8 +18,8 @@ commit when Git is available; it cannot change repository state. - Edge schema: `schemas/edge.schema.json`, version 1. - Result envelope: `schemas/result.schema.json`, version 1. - Changeset schema: `schemas/changeset.schema.json`, version 1. -- Index schema: version 1, disposable and reproducible. -- Core, CLI, and MCP server: version 1.1.0.dev0. +- Index schema: version 2, disposable and reproducible. +- Core, CLI, and MCP server: version 1.2.0.dev0. - Incremental extraction cache: version 1, disposable and reproducible. Schema files describe the generic interchange contract. Runtime validation remains responsible for @@ -104,13 +104,13 @@ random token is part of every accepted URL path. Only `GET` and `HEAD` are suppo no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection, and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph overview, bounded search, exact descriptor-category filtering, exact node content, bounded -incoming-and-outgoing neighborhoods, semantic Flow ancestry, convergence Web context, and one -node's bounded project-confined source file. +incoming-and-outgoing neighborhoods, semantic Flow ancestry, convergence Web context, lazy +function-scoped Logic, and one node's bounded project-confined source file. Descriptor filtering accepts only family, authority, status, or tag plus one exact value. There is no write endpoint, arbitrary query endpoint, static filesystem handler, external asset, or project-selection control. -The `graph-browser@15` template provides mouse-wheel zoom centered on the pointer, left-button drag +The `graph-browser@16` template provides mouse-wheel zoom centered on the pointer, left-button drag pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A four-pixel drag threshold defers pointer capture and preserves node activation for ordinary clicks. Loading another root node fits the viewport to the returned neighborhood, including a useful @@ -141,7 +141,7 @@ supported line, TOML, heading, or text anchors. Both side panels support pointer resizing. The unblurred full inspector supports native resizing, constrained title-bar dragging, and a fixed header/footer surrounding a scrollable body. -The header exposes a Nodes/Flow/Web segmented selector. Nodes displays the complete bounded +The header exposes a Nodes/Flow/Web/Logic segmented selector. Nodes displays the complete bounded neighborhood. Flow displays semantic ancestry ending at the current root. Structural and execution edges retain their declared source-to-target direction. Reads, imports, dependencies, inheritance, and `tested_by` reverse because their declared target feeds or qualifies the source. Documentation @@ -151,10 +151,17 @@ direct root-owned members and execution dependencies into adjacent contributor b does not fan back out through unrelated siblings. These are presentation transforms over the validated snapshot; they do not add or change project relationships. -All three views color edges by relationship semantics and retain direction with visible SVG endpoint +Logic is available only when the focused node owns a stored `LogicProjection`. The browser +retrieves that projection through a bounded, exact-owner endpoint. Entry, condition, action, +control, merge, return, raise, and exit nodes remain outside primary graph search and traversal. +Logic edges retain their declared `TRUE`, `FALSE`, `NEXT`, `CASE`, `LOOP`, `EXCEPTION`, `RETURN`, +`RAISE`, `BREAK`, and `CONTINUE` labels. Hiding a logic node creates a visible omitted-path bridge +between retained predecessors and successors instead of pruning valid downstream control flow. + +Nodes, Flow, and Web color edges by relationship semantics and retain direction with visible SVG endpoint symbols. Line patterns provide a non-color cue. A static canvas key shows the exact symbol, color, label, and visible count for each displayed relation, including a deterministic fallback for -project-defined relations. Nodes, Flow, and Web use the same map. +project-defined relations. Logic uses a separate fixed control-flow map. The browser derives node presentation roles only from the returned bounded graph. The current root is the focus. In Nodes, nodes reachable through outgoing edges are shown as outgoing paths; the @@ -221,4 +228,5 @@ must also implement `load_projection()` so a clean rebuild and equivalence check Logic projections are not primary graph nodes. They remain source-scoped, function-owned, independently cached control-flow data so ordinary search, Nodes, Flow, and Web do not become -statement graphs. +statement graphs. Index schema 2 stores them in dedicated owner, node, and edge tables. Reads are +bounded to one exact function or method owner. diff --git a/docs/INCREMENTAL_INDEXING.md b/docs/INCREMENTAL_INDEXING.md index c93df35..6bd0c87 100644 --- a/docs/INCREMENTAL_INDEXING.md +++ b/docs/INCREMENTAL_INDEXING.md @@ -131,8 +131,10 @@ raises. Logic edges retain relation, display label, and deterministic ordinal. A logic empty until they implement a language analyzer. This boundary prevents thousands of boolean expressions and basic blocks from polluting Nodes, -Flow, Web, ordinary search, or architectural traversal. A future Logic view can request one -function-scoped projection on demand. +Flow, Web, ordinary search, or architectural traversal. The Logic tab and `docforge_get_logic` +request one function-scoped projection on demand. The built-in Python analyzer covers conditions, +short-circuit booleans, loops, `match`, exception paths, returns, and raises. It reports possible +static paths; it does not claim runtime branch outcomes. ## Full rebuilds diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index 4674665..d204079 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -15,6 +15,7 @@ canonical applier implementation. - `docforge_project_info` - `docforge_get_contract` - `docforge_get_node` +- `docforge_get_logic` - `docforge_search` - `docforge_filter_nodes` - `docforge_backlinks` @@ -85,14 +86,16 @@ only through the explicit local CLI integration command. ## Visualization boundary -`docforge_visualize` starts the fixed built-in `graph-browser@15` template against the currently +`docforge_visualize` starts the fixed built-in `graph-browser@16` template against the currently validated derived index. It may focus one stable node, run one bounded lexical query, or open the project overview. The tool returns a loopback URL and exact snapshot identity. The tool cannot select a project, database, template, host, port, filesystem path, or SQL expression. Its HTTP surface is token-bound, read-only, same-origin, and limited to overview, search, exact family/authority/status/tag filtering, node-neighborhood JSON, semantic Flow, -convergence Web, and a bounded project-confined source read for one indexed node. The browser +convergence Web, lazy function-scoped Logic, and a bounded project-confined source read for one +indexed node. `docforge_get_logic` and the browser Logic endpoint accept one exact owner node ID and +return only that bounded stored projection. The browser exposes an exact validated index snapshot. It rejects index replacement or alteration and requires another MCP invocation to refresh. Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button @@ -113,7 +116,10 @@ tooltips and inspectors. Flow presents semantic ancestry with relation-aware dir Web follows bounded structural, dependency, execution, evidence, and contextual contributors into the focus. Direct focus-owned members and execution dependencies become adjacent contributor branches without expanding unrelated siblings. The relationship key is regenerated from each -visible view. The browser runs in a project-bound worker owned by +visible view. Logic displays possible static control paths for a focused function or method without +adding its statement-level nodes to primary search or architectural traversal. Hiding a Logic step +bridges its retained predecessors and successors with an explicit omitted path. The browser runs in +a project-bound worker owned by the separately supervised per-user viewer manager. Standard-input transaction completion and MCP host exit do not close the listener. Repeated visualization requests reuse the current worker while its exact snapshot remains valid. `docforge_visualization_status` reports lifecycle state, and diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index a47c0d4..2edd4e5 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -296,6 +296,30 @@ Adjacent traversal is deliberately bounded. After DocForge includes a direct mem dependency owned by the focus, it continues toward that branch rather than fanning back out through unrelated siblings. Depth and edge limits provide a second guard against an unbounded web. +### Logic: possible control paths + +**Logic** answers: “What decisions and actions can occur inside this function or method?” + +Logic appears when the focused node owns a function-scoped `LogicProjection`. It loads that +projection on demand instead of adding statements and conditions to the primary architecture +graph. The view presents: + +- **Entry** and **Exit** terminals. +- **Decision** cards for `if`, `elif`, compound booleans, loop conditions, `match` cases, and + assertions. +- **Action** cards for executable statement blocks and calls. +- **Control** cards for loops, `break`, and `continue`. +- **Merge** cards where alternate paths converge. +- **Terminal** cards for returns and raised exceptions. + +Edges use explicit labels and independent colors for `TRUE`, `FALSE`, `NEXT`, `CASE`, `LOOP`, +`EXCEPTION`, `RETURN`, `RAISE`, `BREAK`, and `CONTINUE`. Long predicates wrap on the card. The full +expression and source anchor remain available through inspection and source navigation. + +Logic is static analysis. It shows paths the indexed source permits, not the branch that ran for a +particular request or the runtime value of a boolean. Dynamic dispatch, reflection, generated +behavior, and values returned by other processes may require runtime tracing to resolve. + ### Reading graph cards The canvas presents nodes as compact semantic cards rather than anonymous circles: @@ -332,6 +356,9 @@ index, or future graph queries. The focus cannot be hidden; focus another node f - In **Nodes**, hiding removes only the selected node and its incident edges. - In **Flow** and **Web**, hiding removes the selected node, then prunes every upstream ancestor whose only remaining route to the focus passed through it. +- In **Logic**, hiding removes the selected control-flow step and inserts an `omitted` bridge + between its visible predecessors and successors. This preserves the readable path without + pretending the hidden code disappeared from the indexed source. - Descendant nodes between the hidden node and the focus remain visible. - Ancestors with another valid path to the focus remain visible through that alternate path. - The status line reports how many nodes were hidden or isolated. @@ -452,6 +479,7 @@ Example MCP client configuration: - `docforge_project_info` - `docforge_get_contract` - `docforge_get_node` +- `docforge_get_logic` - `docforge_search` - `docforge_filter_nodes` - `docforge_backlinks` @@ -523,8 +551,9 @@ canonical sources first. Incremental compilation then notices those changed sour it never treats an unapplied proposal as canonical. Function-scoped `LogicProjection` data is cached alongside its owning source but remains separate -from the primary Nodes, Flow, and Web graph. This is the storage boundary for a future boolean and -control-flow view without adding every condition and basic block to ordinary graph traversal. +from the primary Nodes, Flow, and Web graph. The Logic tab and `docforge_get_logic` load one +function or method on demand without adding every condition and basic block to ordinary graph +traversal. See [Incremental Adapter Indexing](INCREMENTAL_INDEXING.md) for the complete contract, cache invalidation rules, manual-application lifecycle, and lazy Logic boundary. @@ -606,7 +635,7 @@ ambiguous adapter evidence. ### Full inspector content does not fit DocForge 1.0 uses a fixed header and footer with a scrollable inspector body. If an older page is -still open, stop and reopen the visualization so it loads the current `graph-browser@15` template. +still open, stop and reopen the visualization so it loads the current `graph-browser@16` template. ### Render output is stale diff --git a/pyproject.toml b/pyproject.toml index b48243d..519754c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "docforge" -version = "1.1.0.dev0" +version = "1.2.0.dev0" description = "Project-scoped documentation indexing and context service" readme = "README.md" requires-python = ">=3.12" diff --git a/src/docforge/__init__.py b/src/docforge/__init__.py index 783f59a..fb40bbe 100644 --- a/src/docforge/__init__.py +++ b/src/docforge/__init__.py @@ -11,4 +11,4 @@ __all__ = [ "GenericCanonicalApplier", "Project", ] -__version__ = "1.1.0.dev0" +__version__ = "1.2.0.dev0" diff --git a/src/docforge/adapter_contract.py b/src/docforge/adapter_contract.py index a4b6bab..e1794b1 100644 --- a/src/docforge/adapter_contract.py +++ b/src/docforge/adapter_contract.py @@ -405,6 +405,11 @@ class AdapterProject: None, ) + def logic_projections(self) -> tuple[LogicProjection, ...]: + """Return logic captured by the most recent validated project load.""" + + return self._last_logic + def verify_incremental_equivalence(self) -> dict[str, object]: """Prove the incremental and full loader contracts produce the same graph.""" diff --git a/src/docforge/assets/graph.css b/src/docforge/assets/graph.css index 53f9441..8f6bf0c 100644 --- a/src/docforge/assets/graph.css +++ b/src/docforge/assets/graph.css @@ -26,7 +26,7 @@ header { } header h1 { margin: 0; font-size: 17px; } .view-switch { - position: relative; display: grid; grid-template-columns: repeat(3, 58px); + position: relative; display: grid; grid-template-columns: repeat(4, 58px); flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px; background: #08131f; isolation: isolate; } @@ -38,6 +38,7 @@ header h1 { margin: 0; font-size: 17px; } } .view-switch[data-mode="flow"]::before { transform: translateX(58px); } .view-switch[data-mode="web"]::before { transform: translateX(116px); } +.view-switch[data-mode="logic"]::before { transform: translateX(174px); } .view-switch button { min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px; background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; diff --git a/src/docforge/assets/graph.html b/src/docforge/assets/graph.html index b3da7a0..c857ca7 100644 --- a/src/docforge/assets/graph.html +++ b/src/docforge/assets/graph.html @@ -15,6 +15,7 @@ +

DocForge graph

diff --git a/src/docforge/assets/graph.js b/src/docforge/assets/graph.js index 7c21151..556dc37 100644 --- a/src/docforge/assets/graph.js +++ b/src/docforge/assets/graph.js @@ -4,6 +4,7 @@ const state = { overview: null, graph: null, root: null, + focusNode: null, mode: "nodes", depth: 1, searchLimit: 1, @@ -95,6 +96,50 @@ const relationStyles = Object.freeze({ family: "Context", color: "#94a3b8", dash: "5 5", marker: "open-arrow", flow: null, }, + next: { + family: "Logic", color: "#8da2b8", dash: "", marker: "arrow", + flow: "forward", + }, + when_true: { + family: "Logic", color: "#4ade80", dash: "", marker: "arrow", + flow: "forward", + }, + when_false: { + family: "Logic", color: "#fb7185", dash: "5 3", marker: "arrow", + flow: "forward", + }, + case: { + family: "Logic", color: "#c084fc", dash: "7 3", marker: "arrow", + flow: "forward", + }, + loop: { + family: "Logic", color: "#2dd4bf", dash: "4 3", marker: "double-arrow", + flow: "forward", + }, + exception: { + family: "Logic", color: "#f97316", dash: "3 3", marker: "open-arrow", + flow: "forward", + }, + return: { + family: "Logic", color: "#38bdf8", dash: "", marker: "square-arrow", + flow: "forward", + }, + raise: { + family: "Logic", color: "#f43f5e", dash: "", marker: "square-arrow", + flow: "forward", + }, + break: { + family: "Logic", color: "#fbbf24", dash: "6 3", marker: "open-arrow", + flow: "forward", + }, + continue: { + family: "Logic", color: "#22d3ee", dash: "6 3", marker: "open-arrow", + flow: "forward", + }, + omitted: { + family: "Logic", color: "#64748b", dash: "2 5", marker: "open-arrow", + flow: "forward", + }, }); const contributionStyles = Object.freeze({ focus: { @@ -126,10 +171,30 @@ const contributionStyles = Object.freeze({ related: { label: "Related", section: "Other connections", color: "#fb923c", fill: "#3b2719", }, + "logic-entry": { + label: "Entry", section: "Function boundary", color: "#67e8f9", fill: "#103745", + }, + "logic-condition": { + label: "Decision", section: "Conditions & cases", color: "#facc15", fill: "#3b3112", + }, + "logic-action": { + label: "Action", section: "Actions & calls", color: "#34d399", fill: "#15372e", + }, + "logic-control": { + label: "Control", section: "Loops & exception handling", color: "#c084fc", fill: "#302044", + }, + "logic-merge": { + label: "Merge", section: "Branch convergence", color: "#94a3b8", fill: "#252d39", + }, + "logic-terminal": { + label: "Terminal", section: "Returns, raises & exits", color: "#fb7185", fill: "#41202a", + }, }); const contributionOrder = Object.freeze([ "focus", "composition", "behavior", "dependency", "execution", "data", "evidence", "context", "related", + "logic-entry", "logic-condition", "logic-action", "logic-control", + "logic-merge", "logic-terminal", ]); const compositionRelations = new Set(["contains", "defines", "defined_in"]); const behaviorRelations = new Set(["inherits", "implemented_by"]); @@ -193,12 +258,17 @@ function humanize(value) { } function nodeDisplayName(node) { const title = escapeText(node.title).trim() || escapeText(node.node_id); + if (Array.isArray(node.tags) && node.tags.includes("logic")) return title; if (!title || /\s/.test(title)) return title; const parts = title.split(/::|[./]/).filter(Boolean); return parts.at(-1) || title; } function nodeKindLabel(node) { const tags = new Set(Array.isArray(node.tags) ? node.tags.map(String) : []); + if (tags.has("logic")) { + const kind = [...tags].find((tag) => tag !== "logic"); + return kind ? humanize(kind) : "Logic"; + } const kinds = [ "method", "function", "class", "module", "package", "property", "field", "route", "command", "service", "plugin", "table", "column", "view", @@ -458,6 +528,7 @@ function restoreGraphStatus() { nodes: "neighborhood", flow: "semantic flow", web: "convergence web", + logic: "control flow", }[state.mode]; const pruned = state.prunedCount ? ` · ${state.prunedCount} hidden or isolated` : ""; setStatus(`${state.visibleNodeCount} nodes · ${state.visibleEdgeCount} edges in ${scope}${pruned}`); @@ -623,6 +694,40 @@ function buildWebGraph(data) { ])); return {...data, nodes, edges, topology}; } +function buildLogicGraph(data) { + const nodeIds = new Set(data.nodes.map((node) => node.node_id)); + const edges = data.edges.filter( + (edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id), + ); + const hops = new Map([[data.root, 0]]); + let frontier = [data.root]; + while (frontier.length) { + const next = []; + for (const sourceId of frontier) { + for (const edge of edges) { + if (edge.source_id !== sourceId || hops.has(edge.target_id)) continue; + hops.set(edge.target_id, hops.get(sourceId) + 1); + next.push(edge.target_id); + } + } + frontier = next; + } + const nodes = data.nodes.filter((node) => hops.has(node.node_id)); + return { + ...data, + nodes, + edges: edges.filter( + (edge) => hops.has(edge.source_id) && hops.has(edge.target_id), + ), + topology: new Map(nodes.map((node) => [ + node.node_id, + { + hop: hops.get(node.node_id) ?? 0, + role: node.node_id === data.root ? "primary" : "child", + }, + ])), + }; +} function pruneConvergenceGraph(data, hiddenNodes) { const candidates = new Set( data.nodes @@ -656,7 +761,92 @@ function pruneConvergenceGraph(data, hiddenNodes) { prunedCount: data.nodes.length - reachesFocus.size, }; } +function pruneLogicGraph(data, hiddenNodes) { + const visible = new Set( + data.nodes + .filter((node) => node.node_id === data.root || !hiddenNodes.has(node.node_id)) + .map((node) => node.node_id), + ); + const outgoing = new Map(data.nodes.map((node) => [node.node_id, []])); + for (const edge of data.edges) outgoing.get(edge.source_id)?.push(edge); + const edges = []; + const keys = new Set(); + const append = (edge) => { + const key = `${edge.source_id}\u0000${edge.relation}\u0000${edge.target_id}`; + if (keys.has(key)) return; + keys.add(key); + edges.push(edge); + }; + for (const sourceId of visible) { + const stack = [...(outgoing.get(sourceId) || [])].map( + (edge) => ({edge, omitted: false, seen: new Set([sourceId])}), + ); + while (stack.length) { + const current = stack.pop(); + const targetId = current.edge.target_id; + if (current.seen.has(targetId)) continue; + const seen = new Set(current.seen); + seen.add(targetId); + if (visible.has(targetId)) { + append(current.omitted + ? { + source_id: sourceId, + relation: "omitted", + target_id: targetId, + label: "HIDDEN PATH", + reversed: false, + } + : current.edge); + continue; + } + for (const nextEdge of outgoing.get(targetId) || []) { + stack.push({edge: nextEdge, omitted: true, seen}); + } + } + } + const reachable = new Set([data.root]); + let frontier = [data.root]; + while (frontier.length) { + const next = []; + for (const sourceId of frontier) { + for (const edge of edges) { + if (edge.source_id !== sourceId || reachable.has(edge.target_id)) continue; + reachable.add(edge.target_id); + next.push(edge.target_id); + } + } + frontier = next; + } + const nodes = data.nodes.filter((node) => reachable.has(node.node_id)); + return { + ...data, + nodes, + edges: edges.filter( + (edge) => reachable.has(edge.source_id) && reachable.has(edge.target_id), + ), + topology: buildLogicGraph({ + ...data, + nodes, + edges, + }).topology, + prunedCount: data.nodes.length - nodes.length, + }; +} function nodeContributionCategory(nodeId, data, topology) { + const node = data.nodes.find((candidate) => candidate.node_id === nodeId); + if (Array.isArray(node?.tags) && node.tags.includes("logic")) { + const kind = escapeText(node.logic_kind + || node.tags.find((tag) => tag !== "logic")).toLowerCase(); + if (kind === "entry") return "logic-entry"; + if (["condition", "case"].includes(kind)) return "logic-condition"; + if (["action", "call"].includes(kind)) return "logic-action"; + if (["loop", "try", "except", "finally", "break", "continue"].includes(kind)) { + return "logic-control"; + } + if (kind === "merge") return "logic-merge"; + if (["return", "raise", "exit"].includes(kind)) return "logic-terminal"; + return "logic-action"; + } if (nodeId === data.root) return "focus"; const nodeHop = topology.get(nodeId)?.hop ?? Number.POSITIVE_INFINITY; const candidates = []; @@ -756,6 +946,14 @@ function layoutFlow(nodes, rootId, topology, sizes = nodeSizeMap(nodes, rootId)) } return positions; } +function layoutLogic(nodes, rootId, topology, sizes = nodeSizeMap(nodes, rootId)) { + const positions = layoutFlow(nodes, rootId, topology, sizes); + for (const [nodeId, point] of positions) { + if (nodeId === rootId) continue; + positions.set(nodeId, {...point, x: Math.abs(point.x)}); + } + return positions; +} function darken(hex, amount) { const value = Number.parseInt(hex.slice(1), 16); const factor = 1 - Math.min(.5, Math.max(0, amount)); @@ -798,6 +996,7 @@ function renderNeighborhood(data, topology, categories) { nodes: "Neighborhood", flow: "Semantic flow", web: "Convergence web", + logic: "Control flow", }[state.mode]; renderNodeLegend(categories); const container = $("neighborhood-sections"); @@ -825,7 +1024,9 @@ function renderNeighborhood(data, topology, categories) { button.type = "button"; button.className = "node-list-item"; button.style.setProperty("--item-color", palette.stroke); - button.title = `Focus ${node.title}`; + button.title = state.mode === "logic" + ? `Inspect ${node.title}` + : `Focus ${node.title}`; const swatch = document.createElement("i"); swatch.className = "node-swatch"; const copy = document.createElement("span"); @@ -837,7 +1038,14 @@ function renderNeighborhood(data, topology, categories) { meta.textContent = `${nodeKindLabel(node)} · ${style.label} · ${hopLabel}`; copy.append(title, meta); button.append(swatch, copy); - button.addEventListener("click", () => loadNode(node.node_id)); + button.addEventListener("click", (event) => { + if (state.mode === "logic") { + selectNode(node.node_id); + showNodeCard(node.node_id, event); + } else { + loadNode(node.node_id); + } + }); list.append(button); } container.append(heading, list); @@ -906,7 +1114,9 @@ function renderGraph(data, preserveSelection = false) { : data.root; const completeView = state.mode === "flow" ? buildFlowGraph(data) - : state.mode === "web" ? buildWebGraph(data) : data; + : state.mode === "web" + ? buildWebGraph(data) + : state.mode === "logic" ? buildLogicGraph(data) : data; let view; if (state.mode === "nodes") { const visibleIds = new Set( @@ -923,6 +1133,8 @@ function renderGraph(data, preserveSelection = false) { ), prunedCount: completeView.nodes.length - visibleIds.size, }; + } else if (state.mode === "logic") { + view = pruneLogicGraph(completeView, state.hiddenNodes); } else { view = pruneConvergenceGraph(completeView, state.hiddenNodes); } @@ -945,9 +1157,11 @@ function renderGraph(data, preserveSelection = false) { const topology = view.topology || analyzeTopology(view); const categories = nodeCategoryMap(view, topology); const sizes = nodeSizeMap(view.nodes, view.root); - const positions = state.mode !== "nodes" - ? layoutFlow(view.nodes, view.root, topology, sizes) - : layoutNodes(view.nodes, view.root, topology, sizes); + const positions = state.mode === "logic" + ? layoutLogic(view.nodes, view.root, topology, sizes) + : state.mode !== "nodes" + ? layoutFlow(view.nodes, view.root, topology, sizes) + : layoutNodes(view.nodes, view.root, topology, sizes); state.positions = positions; state.homeViewport = viewportForPositions(positions, sizes); resetViewport(); @@ -986,7 +1200,7 @@ function renderGraph(data, preserveSelection = false) { fill: style.color, "text-anchor": "middle", }); - label.textContent = relationLabel(edge.relation, edge.reversed); + label.textContent = edge.label || relationLabel(edge.relation, edge.reversed); edgeLayer.append(label); } for (const node of view.nodes) { @@ -1093,6 +1307,10 @@ function hideNode(nodeId) { : ""; setStatus(`Hidden ${nodeId}.${suffix} Restore hidden nodes from the graph controls.`); } +function explorationTarget(nodeId) { + const node = state.graph?.nodes.find((candidate) => candidate.node_id === nodeId); + return escapeText(node?.logic_owner_id || nodeId); +} function restoreHiddenNodes() { const count = state.hiddenNodes.size; state.hiddenNodes.clear(); @@ -1334,10 +1552,14 @@ async function loadNode(nodeId) { try { const showingFlow = state.mode === "flow"; const showingWeb = state.mode === "web"; + const showingLogic = state.mode === "logic"; + state.focusNode = nodeId; const action = showingFlow ? "Tracing semantic flow for" - : showingWeb ? "Building convergence web for" : "Loading"; + : showingWeb ? "Building convergence web for" + : showingLogic ? "Tracing control flow for" : "Loading"; setStatus(`${action} ${nodeId}…`); - const endpoint = showingFlow ? "lineage" : showingWeb ? "web" : "node"; + const endpoint = showingFlow ? "lineage" + : showingWeb ? "web" : showingLogic ? "logic" : "node"; const params = new URLSearchParams( showingFlow ? {id: nodeId, limit: "1000"} @@ -1347,16 +1569,20 @@ async function loadNode(nodeId) { depth: String(state.depth), limit: "1000", } - : {id: nodeId, depth: String(state.depth), limit: "100"}, + : showingLogic + ? {id: nodeId} + : {id: nodeId, depth: String(state.depth), limit: "100"}, ); const data = await api(`${endpoint}?${params}`); renderGraph(data); const scope = showingFlow ? "semantic flow" - : showingWeb ? "convergence web" : "neighborhood"; + : showingWeb ? "convergence web" + : showingLogic ? "control flow" : "neighborhood"; const suffix = data.truncated ? " · truncated at the safety limit" : ""; - setStatus( - `${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`, - ); + const status = showingLogic && !data.available + ? `No indexed Python logic is available for ${nodeId}` + : `${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`; + setStatus(status, showingLogic && !data.available); history.replaceState( null, "", @@ -1367,13 +1593,14 @@ async function loadNode(nodeId) { } } async function setViewMode(mode) { - if (!["nodes", "flow", "web"].includes(mode)) return; + if (!["nodes", "flow", "web", "logic"].includes(mode)) 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")); $("view-web").setAttribute("aria-pressed", String(mode === "web")); - if (state.root) await loadNode(state.root); + $("view-logic").setAttribute("aria-pressed", String(mode === "logic")); + if (state.focusNode) await loadNode(state.focusNode); } function clamp(value, minimum, maximum) { return Math.min(maximum, Math.max(minimum, value)); @@ -1474,6 +1701,7 @@ $("clear-result-filter").addEventListener("click", () => { $("view-nodes").addEventListener("click", () => setViewMode("nodes")); $("view-flow").addEventListener("click", () => setViewMode("flow")); $("view-web").addEventListener("click", () => setViewMode("web")); +$("view-logic").addEventListener("click", () => setViewMode("logic")); $("zoom-in").addEventListener("click", () => zoomAt(.8)); $("zoom-out").addEventListener("click", () => zoomAt(1.25)); $("reset-view").addEventListener("click", resetViewport); @@ -1491,7 +1719,7 @@ $("node-dialog").querySelector(".dialog-head").addEventListener("pointercancel", $("explore-node").addEventListener("click", async () => { const nodeId = state.inspectedNode; closeNodeDialog(); - if (nodeId) await loadNode(nodeId); + if (nodeId) await loadNode(explorationTarget(nodeId)); }); $("open-node-source").addEventListener("click", () => { if (state.inspectedNode) openSource(state.inspectedNode); @@ -1508,7 +1736,7 @@ $("hide-card-node").addEventListener("click", () => { $("explore-card-node").addEventListener("click", async () => { const nodeId = state.cardNode; closeNodeCard(); - if (nodeId) await loadNode(nodeId); + if (nodeId) await loadNode(explorationTarget(nodeId)); }); $("node-dialog").addEventListener("click", (event) => { if (event.target !== $("node-dialog")) return; @@ -1607,7 +1835,9 @@ applyViewport(); const params = new URLSearchParams(location.search); state.depth = Math.max(1, Number(params.get("depth")) || 1); const requestedView = params.get("view"); - setViewMode(["flow", "web"].includes(requestedView) ? requestedView : "nodes"); + setViewMode( + ["flow", "web", "logic"].includes(requestedView) ? requestedView : "nodes", + ); const overview = await api("overview"); renderOverview(overview); startViewerLease(); diff --git a/src/docforge/index.py b/src/docforge/index.py index 685f385..9c25561 100644 --- a/src/docforge/index.py +++ b/src/docforge/index.py @@ -17,6 +17,10 @@ from .models import ( BuildReportingProject, Edge, IncrementalStateProject, + LogicEdge, + LogicNode, + LogicProject, + LogicProjection, Node, ProjectService, ProjectSnapshot, @@ -24,7 +28,7 @@ from .models import ( ) from .project import project_root_fingerprint -INDEX_SCHEMA_VERSION = 1 +INDEX_SCHEMA_VERSION = 2 APPLICATION_ID = 1_146_683_778 @@ -42,6 +46,15 @@ def _edge_hash(edges: tuple[Edge, ...]) -> str: return hashlib.sha256(payload).hexdigest() +def _logic_hash(projections: tuple[LogicProjection, ...]) -> str: + payload = json.dumps( + [projection.as_dict() for projection in projections], + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(payload).hexdigest() + + def _connect_read_only(path: Path) -> sqlite3.Connection: if not path.is_file(): raise DocForgeError("missing_index", "Derived index does not exist; run build first") @@ -65,7 +78,10 @@ def _read_connection(path: Path) -> Generator[sqlite3.Connection, None, None]: connection.close() -def _status(snapshot: ProjectSnapshot) -> dict[str, object]: +def _status( + snapshot: ProjectSnapshot, + logic: tuple[LogicProjection, ...], +) -> dict[str, object]: return { "project_id": snapshot.descriptor.project_id, "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root), @@ -75,6 +91,10 @@ def _status(snapshot: ProjectSnapshot) -> dict[str, object]: "node_count": len(snapshot.nodes), "edge_hash": _edge_hash(snapshot.edges), "edge_count": len(snapshot.edges), + "logic_hash": _logic_hash(logic), + "logic_projection_count": len(logic), + "logic_node_count": sum(len(projection.nodes) for projection in logic), + "logic_edge_count": sum(len(projection.edges) for projection in logic), "index_schema_version": INDEX_SCHEMA_VERSION, "adapter": snapshot.descriptor.adapter, "status": "ok", @@ -93,7 +113,8 @@ class ProjectIndex: def build(self) -> dict[str, object]: snapshot = self.project.load() - status = _status(snapshot) + logic = self._logic_projections() + status = _status(snapshot, logic) build_report = ( self.project.build_report() if isinstance(self.project, BuildReportingProject) else None ) @@ -133,6 +154,32 @@ class ProjectIndex: PRIMARY KEY (source_id, relation, target_id) ); CREATE INDEX edges_target ON edges(target_id, relation, source_id); + CREATE TABLE logic_owners ( + owner_node_id TEXT PRIMARY KEY, + source_id TEXT NOT NULL + ); + CREATE TABLE logic_nodes ( + owner_node_id TEXT NOT NULL, + logic_id TEXT NOT NULL, + kind TEXT NOT NULL, + label TEXT NOT NULL, + source_anchor TEXT, + PRIMARY KEY (owner_node_id, logic_id) + ); + CREATE INDEX logic_nodes_id ON logic_nodes(logic_id, owner_node_id); + CREATE TABLE logic_edges ( + owner_node_id TEXT NOT NULL, + source_id TEXT NOT NULL, + relation TEXT NOT NULL, + target_id TEXT NOT NULL, + label TEXT, + ordinal INTEGER NOT NULL, + PRIMARY KEY ( + owner_node_id, source_id, ordinal, relation, target_id + ) + ); + CREATE INDEX logic_edges_target + ON logic_edges(owner_node_id, target_id, source_id); CREATE VIRTUAL TABLE node_fts USING fts5( node_id UNINDEXED, title, summary, content, tags ); @@ -167,6 +214,39 @@ class ProjectIndex: "INSERT INTO edges VALUES (?, ?, ?)", [(edge.source_id, edge.relation, edge.target_id) for edge in snapshot.edges], ) + connection.executemany( + "INSERT INTO logic_owners VALUES (?, ?)", + [(projection.owner_node_id, projection.source_id) for projection in logic], + ) + connection.executemany( + "INSERT INTO logic_nodes VALUES (?, ?, ?, ?, ?)", + [ + ( + projection.owner_node_id, + node.logic_id, + node.kind, + node.label, + node.source_anchor, + ) + for projection in logic + for node in projection.nodes + ], + ) + connection.executemany( + "INSERT INTO logic_edges VALUES (?, ?, ?, ?, ?, ?)", + [ + ( + projection.owner_node_id, + edge.source_id, + edge.relation, + edge.target_id, + edge.label, + edge.ordinal, + ) + for projection in logic + for edge in projection.edges + ], + ) connection.executemany( "INSERT INTO node_fts VALUES (?, ?, ?, ?, ?)", [ @@ -187,7 +267,12 @@ class ProjectIndex: finally: connection.close() current = self.project.load() - if current.source_hash != snapshot.source_hash or current.revision != snapshot.revision: + current_logic = self._logic_projections() + if ( + current.source_hash != snapshot.source_hash + or current.revision != snapshot.revision + or current_logic != logic + ): raise DocForgeError("source_changed", "Canonical source changed during index build") os.replace(temporary, self.path) except sqlite3.Error as error: @@ -201,13 +286,19 @@ class ProjectIndex: result["build"] = build_report return result + def _logic_projections(self) -> tuple[LogicProjection, ...]: + if isinstance(self.project, LogicProject): + return self.project.logic_projections() + return () + def check(self) -> dict[str, object]: if isinstance(self.project, IncrementalStateProject): state = self.project.incremental_state() if state is not None: return self._check_incremental_state(state) snapshot = self.project.load() - expected = _status(snapshot) + logic = self._logic_projections() + expected = _status(snapshot, logic) with _read_connection(self.path) as connection: application_id = connection.execute("PRAGMA application_id").fetchone()[0] schema_version = connection.execute("PRAGMA user_version").fetchone()[0] @@ -223,6 +314,10 @@ class ProjectIndex: "node_count", "edge_hash", "edge_count", + "logic_hash", + "logic_projection_count", + "logic_node_count", + "logic_edge_count", "index_schema_version", "adapter", ): @@ -244,10 +339,12 @@ class ProjectIndex: "ORDER BY source_id, relation, target_id" ) ) + indexed_logic = _logic_from_connection(connection) fts_count = connection.execute("SELECT COUNT(*) FROM node_fts").fetchone()[0] if ( indexed_nodes != snapshot.nodes or indexed_edges != snapshot.edges + or indexed_logic != logic or fts_count != len(snapshot.nodes) ): raise DocForgeError("invalid_index", "Derived index rows do not match source") @@ -290,14 +387,22 @@ class ProjectIndex: "ORDER BY source_id, relation, target_id" ) ) + indexed_logic = _logic_from_connection(connection) node_hash = _node_hash(indexed_nodes) edge_hash = _edge_hash(indexed_edges) + logic_hash = _logic_hash(indexed_logic) fts_count = connection.execute("SELECT COUNT(*) FROM node_fts").fetchone()[0] if ( metadata.get("node_hash") != node_hash or metadata.get("edge_hash") != edge_hash + or metadata.get("logic_hash") != logic_hash or metadata.get("node_count") != str(len(indexed_nodes)) or metadata.get("edge_count") != str(len(indexed_edges)) + or metadata.get("logic_projection_count") != str(len(indexed_logic)) + or metadata.get("logic_node_count") + != str(sum(len(projection.nodes) for projection in indexed_logic)) + or metadata.get("logic_edge_count") + != str(sum(len(projection.edges) for projection in indexed_logic)) or fts_count != len(indexed_nodes) ): raise DocForgeError("invalid_index", "Derived index rows do not match metadata") @@ -307,6 +412,10 @@ class ProjectIndex: "node_count": len(indexed_nodes), "edge_hash": edge_hash, "edge_count": len(indexed_edges), + "logic_hash": logic_hash, + "logic_projection_count": len(indexed_logic), + "logic_node_count": sum(len(projection.nodes) for projection in indexed_logic), + "logic_edge_count": sum(len(projection.edges) for projection in indexed_logic), "status": "ok", "database": str(self.path), } @@ -321,6 +430,28 @@ class ProjectIndex: ) return self._result(checked, node=_row_to_node(row).as_dict()) + def get_logic(self, owner_node_id: str) -> dict[str, object]: + """Return one function-scoped control-flow projection without expanding the graph.""" + + checked = self.check() + with _read_connection(self.path) as connection: + owner = connection.execute( + "SELECT * FROM nodes WHERE node_id = ?", (owner_node_id,) + ).fetchone() + projection = _logic_projection_from_connection(connection, owner_node_id) + if owner is None: + raise DocForgeError( + "missing_node", + "No node has the requested stable ID", + node_id=owner_node_id, + ) + return self._result( + checked, + owner=_row_to_node(owner).as_dict(include_content=False), + available=projection is not None, + projection=projection.as_dict() if projection is not None else None, + ) + def search(self, query: str, *, limit: int | None = None) -> dict[str, object]: checked = self.check() limits = self.project.descriptor.limits @@ -492,6 +623,64 @@ def _row_to_node(row: sqlite3.Row) -> Node: ) +def _logic_projection_from_connection( + connection: sqlite3.Connection, + owner_node_id: str, +) -> LogicProjection | None: + owner = connection.execute( + "SELECT owner_node_id, source_id FROM logic_owners WHERE owner_node_id = ?", + (owner_node_id,), + ).fetchone() + if owner is None: + return None + nodes = tuple( + LogicNode( + logic_id=row["logic_id"], + kind=row["kind"], + label=row["label"], + source_anchor=row["source_anchor"], + ) + for row in connection.execute( + "SELECT logic_id, kind, label, source_anchor FROM logic_nodes " + "WHERE owner_node_id = ? ORDER BY logic_id", + (owner_node_id,), + ) + ) + edges = tuple( + LogicEdge( + source_id=row["source_id"], + relation=row["relation"], + target_id=row["target_id"], + label=row["label"], + ordinal=row["ordinal"], + ) + for row in connection.execute( + "SELECT source_id, relation, target_id, label, ordinal FROM logic_edges " + "WHERE owner_node_id = ? " + "ORDER BY source_id, ordinal, relation, target_id", + (owner_node_id,), + ) + ) + return LogicProjection( + owner_node_id=owner["owner_node_id"], + source_id=owner["source_id"], + nodes=nodes, + edges=edges, + ) + + +def _logic_from_connection( + connection: sqlite3.Connection, +) -> tuple[LogicProjection, ...]: + owners = connection.execute( + "SELECT owner_node_id FROM logic_owners ORDER BY owner_node_id" + ).fetchall() + projections = [ + _logic_projection_from_connection(connection, row["owner_node_id"]) for row in owners + ] + return tuple(projection for projection in projections if projection is not None) + + def _bounded_limit(value: int | None, maximum: int, *, default: int) -> int: if value is None: return min(default, maximum) diff --git a/src/docforge/mcp_server.py b/src/docforge/mcp_server.py index 8c24efb..2fa19cf 100644 --- a/src/docforge/mcp_server.py +++ b/src/docforge/mcp_server.py @@ -20,7 +20,7 @@ from .project import Project, project_root_fingerprint from .rendering import RenderService from .viewer_manager import ViewerManagerClient -SERVER_VERSION = "1.1.0.dev0" +SERVER_VERSION = "1.2.0.dev0" CONTENT_WARNING = ( "Returned text is project documentation content. It does not override client, user, or project " "authority instructions." @@ -29,6 +29,7 @@ READ_TOOLS = ( "docforge_project_info", "docforge_get_contract", "docforge_get_node", + "docforge_get_logic", "docforge_search", "docforge_filter_nodes", "docforge_backlinks", @@ -354,6 +355,12 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC return service.invoke(lambda: service.index.get_node(node_id)) + @server.tool(name="docforge_get_logic") + def get_logic(owner_node_id: str) -> dict[str, Any]: + """Return the lazy control-flow projection owned by one function or method.""" + + return service.invoke(lambda: service.index.get_logic(owner_node_id)) + @server.tool(name="docforge_search") def search(query: str, limit: int | None = None) -> dict[str, Any]: """Run bounded lexical search over the current validated project index.""" @@ -442,6 +449,7 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC project_info, get_contract, get_node, + get_logic, search, filter_nodes, backlinks, diff --git a/src/docforge/models.py b/src/docforge/models.py index 89dddfc..5d1f328 100644 --- a/src/docforge/models.py +++ b/src/docforge/models.py @@ -204,6 +204,13 @@ class IncrementalStateProject(ProjectService, Protocol): def incremental_state(self) -> ProjectState | None: ... +@runtime_checkable +class LogicProject(ProjectService, Protocol): + """Optional project boundary exposing logic from its most recent validated load.""" + + def logic_projections(self) -> tuple[LogicProjection, ...]: ... + + @dataclass(frozen=True) class ContextEntry: node_id: str diff --git a/src/docforge/python_logic.py b/src/docforge/python_logic.py new file mode 100644 index 0000000..2cc7dee --- /dev/null +++ b/src/docforge/python_logic.py @@ -0,0 +1,516 @@ +"""Deterministic, function-scoped Python control-flow extraction. + +The analyzer parses source as data. It never imports or executes project code. +Its projections intentionally remain separate from DocForge's primary graph. +""" + +from __future__ import annotations + +import ast +import hashlib +from collections.abc import Iterable +from dataclasses import dataclass + +from .errors import DocForgeError +from .models import LogicEdge, LogicNode, LogicProjection + +FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef + + +@dataclass(frozen=True) +class PythonLogicOwner: + """One primary graph function or method that should receive a logic projection.""" + + owner_node_id: str + qualified_name: str + line: int + + +@dataclass(frozen=True) +class _Tail: + source_id: str + relation: str = "next" + label: str | None = None + + +@dataclass(frozen=True) +class _Condition: + entry_id: str + when_true: tuple[_Tail, ...] + when_false: tuple[_Tail, ...] + + +@dataclass(frozen=True) +class _Loop: + continue_id: str + break_id: str + + +def analyze_python_source( + source: str, + *, + source_id: str, + owners: Iterable[PythonLogicOwner], + filename: str = "", + max_nodes_per_function: int = 2_000, +) -> tuple[LogicProjection, ...]: + """Build ordered control-flow projections for explicitly owned Python functions.""" + + if max_nodes_per_function < 2: + raise ValueError("max_nodes_per_function must allow entry and exit nodes") + try: + tree = ast.parse(source, filename=filename) + except SyntaxError as error: + raise DocForgeError( + "invalid_logic_source", + "Python source cannot be parsed for logic analysis", + source=filename, + line=error.lineno, + ) from error + definitions = _function_definitions(tree) + requested = tuple(sorted(owners, key=lambda item: item.owner_node_id)) + if len({owner.owner_node_id for owner in requested}) != len(requested): + raise DocForgeError("invalid_logic_owner", "Logic owner IDs must be unique") + projections: list[LogicProjection] = [] + for owner in requested: + function = definitions.get((owner.qualified_name, owner.line)) + if function is None: + raise DocForgeError( + "missing_logic_owner", + "A requested Python logic owner was not found in its source", + owner_node_id=owner.owner_node_id, + qualified_name=owner.qualified_name, + line=owner.line, + ) + projections.append( + _FunctionLogicBuilder( + source_id=source_id, + owner_node_id=owner.owner_node_id, + function=function, + max_nodes=max_nodes_per_function, + ).build() + ) + return tuple(projections) + + +def _function_definitions(tree: ast.Module) -> dict[tuple[str, int], FunctionNode]: + result: dict[tuple[str, int], FunctionNode] = {} + + class DefinitionVisitor(ast.NodeVisitor): + def __init__(self) -> None: + self.parents: tuple[str, ...] = () + + def _visit_scope(self, name: str, body: list[ast.stmt]) -> None: + previous = self.parents + self.parents = (*previous, name) + for statement in body: + self.visit(statement) + self.parents = previous + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._visit_scope(node.name, node.body) + + def visit_FunctionDef(self, node: ast.FunctionDef) -> None: + qualified_name = ".".join((*self.parents, node.name)) + result[(qualified_name, node.lineno)] = node + self._visit_scope(node.name, node.body) + + def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: + qualified_name = ".".join((*self.parents, node.name)) + result[(qualified_name, node.lineno)] = node + self._visit_scope(node.name, node.body) + + DefinitionVisitor().visit(tree) + return result + + +class _FunctionLogicBuilder: + def __init__( + self, + *, + source_id: str, + owner_node_id: str, + function: FunctionNode, + max_nodes: int, + ) -> None: + self.source_id = source_id + self.owner_node_id = owner_node_id + self.function = function + self.max_nodes = max_nodes + self.nodes: list[LogicNode] = [] + self.edges: list[LogicEdge] = [] + self._edge_ordinals: dict[str, int] = {} + self._sequence = 0 + self._owner_digest = hashlib.sha256(owner_node_id.encode()).hexdigest()[:12] + self.entry_id = self._node("entry", f"Enter {function.name}", function) + self.exit_id = self._node("exit", f"Exit {function.name}", function) + + def build(self) -> LogicProjection: + incoming = (_Tail(self.entry_id),) + body = list(self.function.body) + if body and _is_docstring(body[0]): + body = body[1:] + tails = self._statements(body, incoming, loop=None) + self._connect(tails, self.exit_id) + if not self._has_incoming(self.exit_id): + self._edge(self.entry_id, "next", self.exit_id, "END") + return LogicProjection( + owner_node_id=self.owner_node_id, + source_id=self.source_id, + nodes=tuple(sorted(self.nodes, key=lambda node: node.logic_id)), + edges=tuple( + sorted( + self.edges, + key=lambda edge: ( + edge.source_id, + edge.ordinal, + edge.relation, + edge.target_id, + edge.label or "", + ), + ) + ), + ) + + def _statements( + self, + statements: list[ast.stmt], + incoming: tuple[_Tail, ...], + *, + loop: _Loop | None, + ) -> tuple[_Tail, ...]: + tails = incoming + for statement in statements: + if not tails: + break + tails = self._statement(statement, tails, loop=loop) + return tails + + def _statement( + self, + statement: ast.stmt, + incoming: tuple[_Tail, ...], + *, + loop: _Loop | None, + ) -> tuple[_Tail, ...]: + if isinstance(statement, ast.If): + return self._if(statement, incoming, loop=loop) + if isinstance(statement, (ast.While,)): + return self._while(statement, incoming) + if isinstance(statement, (ast.For, ast.AsyncFor)): + return self._for(statement, incoming) + if isinstance(statement, ast.Match): + return self._match(statement, incoming, loop=loop) + if isinstance(statement, (ast.Try, ast.TryStar)): + return self._try(statement, incoming, loop=loop) + if isinstance(statement, (ast.With, ast.AsyncWith)): + label = f"{'async ' if isinstance(statement, ast.AsyncWith) else ''}with " + label += ", ".join(_expression(item.context_expr) for item in statement.items) + node_id = self._node("action", label, statement) + self._connect(incoming, node_id) + return self._statements(statement.body, (_Tail(node_id),), loop=loop) + if isinstance(statement, ast.Return): + label = ( + "return" if statement.value is None else f"return {_expression(statement.value)}" + ) + node_id = self._node("return", label, statement) + self._connect(incoming, node_id) + self._edge(node_id, "return", self.exit_id, "RETURN") + return () + if isinstance(statement, ast.Raise): + label = "raise" if statement.exc is None else f"raise {_expression(statement.exc)}" + node_id = self._node("raise", label, statement) + self._connect(incoming, node_id) + self._edge(node_id, "raise", self.exit_id, "RAISE") + return () + if isinstance(statement, ast.Break): + node_id = self._node("break", "break", statement) + self._connect(incoming, node_id) + if loop is not None: + self._edge(node_id, "break", loop.break_id, "BREAK") + else: + self._edge(node_id, "next", self.exit_id, "INVALID BREAK") + return () + if isinstance(statement, ast.Continue): + node_id = self._node("continue", "continue", statement) + self._connect(incoming, node_id) + if loop is not None: + self._edge(node_id, "continue", loop.continue_id, "CONTINUE") + else: + self._edge(node_id, "next", self.exit_id, "INVALID CONTINUE") + return () + if isinstance(statement, ast.Assert): + condition = self._condition(statement.test, incoming) + failure = self._node( + "raise", + "AssertionError" + if statement.msg is None + else f"AssertionError: {_expression(statement.msg)}", + statement, + ) + self._connect(condition.when_false, failure) + self._edge(failure, "raise", self.exit_id, "RAISE") + return condition.when_true + + kind = "call" if _contains_runtime_call(statement) else "action" + node_id = self._node(kind, _statement_label(statement), statement) + self._connect(incoming, node_id) + return (_Tail(node_id),) + + def _if( + self, + statement: ast.If, + incoming: tuple[_Tail, ...], + *, + loop: _Loop | None, + ) -> tuple[_Tail, ...]: + condition = self._condition(statement.test, incoming) + body_tails = self._statements(statement.body, condition.when_true, loop=loop) + else_tails = ( + self._statements(statement.orelse, condition.when_false, loop=loop) + if statement.orelse + else condition.when_false + ) + return self._merge("Branch merge", (*body_tails, *else_tails), statement) + + def _while(self, statement: ast.While, incoming: tuple[_Tail, ...]) -> tuple[_Tail, ...]: + condition = self._condition(statement.test, incoming) + after_id = self._node("merge", "After loop", statement) + loop = _Loop(continue_id=condition.entry_id, break_id=after_id) + body_tails = self._statements(statement.body, condition.when_true, loop=loop) + for tail in body_tails: + self._edge(tail.source_id, "loop", condition.entry_id, "LOOP") + normal_tails = ( + self._statements(statement.orelse, condition.when_false, loop=None) + if statement.orelse + else condition.when_false + ) + self._connect(normal_tails, after_id) + return (_Tail(after_id),) if self._has_incoming(after_id) else () + + def _for( + self, + statement: ast.For | ast.AsyncFor, + incoming: tuple[_Tail, ...], + ) -> tuple[_Tail, ...]: + prefix = "async for" if isinstance(statement, ast.AsyncFor) else "for" + loop_id = self._node( + "loop", + f"{prefix} {_expression(statement.target)} in {_expression(statement.iter)}", + statement, + ) + after_id = self._node("merge", "After loop", statement) + self._connect(incoming, loop_id) + loop = _Loop(continue_id=loop_id, break_id=after_id) + body_tails = self._statements( + statement.body, + (_Tail(loop_id, "when_true", "ITEM"),), + loop=loop, + ) + for tail in body_tails: + self._edge(tail.source_id, "loop", loop_id, "NEXT ITEM") + exhausted = (_Tail(loop_id, "when_false", "EXHAUSTED"),) + normal_tails = ( + self._statements(statement.orelse, exhausted, loop=None) + if statement.orelse + else exhausted + ) + self._connect(normal_tails, after_id) + return (_Tail(after_id),) if self._has_incoming(after_id) else () + + def _match( + self, + statement: ast.Match, + incoming: tuple[_Tail, ...], + *, + loop: _Loop | None, + ) -> tuple[_Tail, ...]: + match_id = self._node("condition", f"match {_expression(statement.subject)}", statement) + self._connect(incoming, match_id) + pending: tuple[_Tail, ...] = (_Tail(match_id, "case", "CASE"),) + completed: list[_Tail] = [] + for case in statement.cases: + label = f"case {_expression(case.pattern)}" + if case.guard is not None: + label += f" if {_expression(case.guard)}" + case_id = self._node("case", label, case.pattern) + self._connect(pending, case_id) + completed.extend( + self._statements( + case.body, + (_Tail(case_id, "when_true", "MATCH"),), + loop=loop, + ) + ) + pending = () if _is_catch_all(case) else (_Tail(case_id, "when_false", "NEXT CASE"),) + return self._merge("Match merge", (*completed, *pending), statement) + + def _try( + self, + statement: ast.Try | ast.TryStar, + incoming: tuple[_Tail, ...], + *, + loop: _Loop | None, + ) -> tuple[_Tail, ...]: + try_id = self._node("try", "try", statement) + self._connect(incoming, try_id) + normal = self._statements(statement.body, (_Tail(try_id),), loop=loop) + if statement.orelse: + normal = self._statements(statement.orelse, normal, loop=loop) + branches: list[_Tail] = list(normal) + for handler in statement.handlers: + exception = "Exception" if handler.type is None else _expression(handler.type) + if handler.name: + exception += f" as {handler.name}" + handler_id = self._node("except", f"except {exception}", handler) + self._edge(try_id, "exception", handler_id, f"EXCEPT {exception}") + branches.extend(self._statements(handler.body, (_Tail(handler_id),), loop=loop)) + merged = self._merge("Try merge", tuple(branches), statement) + if not statement.finalbody: + return merged + finally_id = self._node("finally", "finally", statement.finalbody[0]) + self._connect(merged, finally_id) + return self._statements(statement.finalbody, (_Tail(finally_id),), loop=loop) + + def _condition( + self, + expression: ast.expr, + incoming: tuple[_Tail, ...], + ) -> _Condition: + if isinstance(expression, ast.UnaryOp) and isinstance(expression.op, ast.Not): + inner = self._condition(expression.operand, incoming) + return _Condition(inner.entry_id, inner.when_false, inner.when_true) + if isinstance(expression, ast.BoolOp) and expression.values: + first = self._condition(expression.values[0], incoming) + entry_id = first.entry_id + if isinstance(expression.op, ast.And): + when_true = first.when_true + when_false = list(first.when_false) + for value in expression.values[1:]: + next_condition = self._condition(value, when_true) + when_true = next_condition.when_true + when_false.extend(next_condition.when_false) + return _Condition(entry_id, when_true, tuple(when_false)) + when_true = list(first.when_true) + when_false = first.when_false + for value in expression.values[1:]: + next_condition = self._condition(value, when_false) + when_true.extend(next_condition.when_true) + when_false = next_condition.when_false + return _Condition(entry_id, tuple(when_true), when_false) + node_id = self._node("condition", _expression(expression), expression) + self._connect(incoming, node_id) + return _Condition( + node_id, + (_Tail(node_id, "when_true", "TRUE"),), + (_Tail(node_id, "when_false", "FALSE"),), + ) + + def _merge( + self, + label: str, + incoming: tuple[_Tail, ...], + source: ast.AST, + ) -> tuple[_Tail, ...]: + if not incoming: + return () + merge_id = self._node("merge", label, source) + self._connect(incoming, merge_id) + return (_Tail(merge_id),) + + def _node(self, kind: str, label: str, source: ast.AST) -> str: + if len(self.nodes) >= self.max_nodes: + raise DocForgeError( + "logic_too_large", + "A function exceeds the configured logic-node safety boundary", + owner_node_id=self.owner_node_id, + maximum=self.max_nodes, + ) + line = max(1, int(getattr(source, "lineno", self.function.lineno))) + column = max(0, int(getattr(source, "col_offset", 0))) + self._sequence += 1 + logic_id = f"logic.{self._owner_digest}.{kind}.{line}.{column}.{self._sequence}" + self.nodes.append( + LogicNode( + logic_id=logic_id, + kind=kind, + label=label, + source_anchor=f"L{line}", + ) + ) + return logic_id + + def _connect(self, incoming: tuple[_Tail, ...], target_id: str) -> None: + for tail in incoming: + self._edge(tail.source_id, tail.relation, target_id, tail.label) + + def _edge( + self, + source_id: str, + relation: str, + target_id: str, + label: str | None, + ) -> None: + ordinal = self._edge_ordinals.get(source_id, 0) + self._edge_ordinals[source_id] = ordinal + 1 + self.edges.append( + LogicEdge( + source_id=source_id, + relation=relation, + target_id=target_id, + label=label, + ordinal=ordinal, + ) + ) + + def _has_incoming(self, node_id: str) -> bool: + return any(edge.target_id == node_id for edge in self.edges) + + +def _is_docstring(statement: ast.stmt) -> bool: + return ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ) + + +def _is_catch_all(case: ast.match_case) -> bool: + return ( + case.guard is None + and isinstance(case.pattern, ast.MatchAs) + and case.pattern.pattern is None + and case.pattern.name is None + ) + + +def _expression(node: ast.AST) -> str: + try: + value = ast.unparse(node) + except (AttributeError, ValueError): + value = node.__class__.__name__ + return " ".join(value.split()) + + +def _statement_label(statement: ast.stmt) -> str: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + prefix = "async " if isinstance(statement, ast.AsyncFunctionDef) else "" + return f"define {prefix}function {statement.name}" + if isinstance(statement, ast.ClassDef): + return f"define class {statement.name}" + if isinstance(statement, ast.Pass): + return "pass" + return _expression(statement) + + +def _contains_runtime_call(statement: ast.stmt) -> bool: + nested_definitions = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda) + stack: list[ast.AST] = [statement] + while stack: + current = stack.pop() + if current is not statement and isinstance(current, nested_definitions): + continue + if isinstance(current, (ast.Call, ast.Await)): + return True + stack.extend(ast.iter_child_nodes(current)) + return False diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py index 5d91fd2..e46eee5 100644 --- a/src/docforge/visualization.py +++ b/src/docforge/visualization.py @@ -34,7 +34,7 @@ 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@15" +VISUALIZATION_TEMPLATE = "graph-browser@16" DEFAULT_EDGE_LIMIT = 100 MAX_EDGE_LIMIT = 400 MAX_LINEAGE_EDGE_LIMIT = 1_000 @@ -277,10 +277,69 @@ class VisualizationIndexSnapshot: "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, + logic_row = connection.execute( + "SELECT owner_node_id, logic_id, kind, label, source_anchor " + "FROM logic_nodes WHERE logic_id = ? " + "ORDER BY owner_node_id LIMIT 1", + (node_id,), + ).fetchone() + if logic_row is None: + raise DocForgeError( + "missing_node", + "No node has the requested stable ID", + node_id=node_id, + ) + owner_row = connection.execute( + "SELECT * FROM nodes WHERE node_id = ?", + (logic_row["owner_node_id"],), + ).fetchone() + if owner_row is None: + raise DocForgeError( + "invalid_index", + "Logic projection owner is missing from the primary graph", + ) + logic_nodes = connection.execute( + "SELECT logic_id, kind, label, source_anchor FROM logic_nodes " + "WHERE owner_node_id = ? ORDER BY logic_id", + (logic_row["owner_node_id"],), + ).fetchall() + logic_edges = connection.execute( + "SELECT source_id, relation, target_id, label, ordinal " + "FROM logic_edges WHERE owner_node_id = ? " + "ORDER BY source_id, ordinal, relation, target_id", + (logic_row["owner_node_id"],), + ).fetchall() + node = _logic_node_dict( + logic_row, + owner_row=owner_row, + owner_node_id=logic_row["owner_node_id"], + ) + return self._result( + root=node_id, + depth=1, + edge_limit=limit, + truncated=False, + node=node, + nodes=[ + _logic_node_dict( + row, + owner_row=owner_row, + owner_node_id=logic_row["owner_node_id"], + ) + for row in logic_nodes + ], + edges=[ + { + "source_id": row["source_id"], + "relation": row["relation"], + "target_id": row["target_id"], + "label": row["label"], + "ordinal": row["ordinal"], + "reversed": False, + } + for row in logic_edges + ], + snapshot=True, ) visited = {node_id} frontier = {node_id} @@ -342,6 +401,20 @@ class VisualizationIndexSnapshot: "SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?", (node_id,), ).fetchone() + if row is None: + row = connection.execute( + """ + SELECT logic.logic_id AS node_id, + owner.source_path AS source_path, + logic.source_anchor AS source_anchor + FROM logic_nodes AS logic + JOIN nodes AS owner ON owner.node_id = logic.owner_node_id + WHERE logic.logic_id = ? + ORDER BY logic.owner_node_id + LIMIT 1 + """, + (node_id,), + ).fetchone() if row is None: raise DocForgeError( "missing_node", @@ -396,6 +469,75 @@ class VisualizationIndexSnapshot: snapshot=True, ) + def logic(self, owner_node_id: str) -> dict[str, object]: + """Return one lazy function-scoped control-flow projection.""" + + with self._connection() as connection: + owner_row = connection.execute( + "SELECT * FROM nodes WHERE node_id = ?", + (owner_node_id,), + ).fetchone() + if owner_row is None: + raise DocForgeError( + "missing_node", + "No node has the requested stable ID", + node_id=owner_node_id, + ) + owner = connection.execute( + "SELECT source_id FROM logic_owners WHERE owner_node_id = ?", + (owner_node_id,), + ).fetchone() + if owner is None: + return self._result( + root=owner_node_id, + logic=True, + available=False, + owner=_node_dict(owner_row, include_content=False), + nodes=[], + edges=[], + snapshot=True, + ) + node_rows = connection.execute( + "SELECT logic_id, kind, label, source_anchor FROM logic_nodes " + "WHERE owner_node_id = ? ORDER BY logic_id", + (owner_node_id,), + ).fetchall() + edge_rows = connection.execute( + "SELECT source_id, relation, target_id, label, ordinal " + "FROM logic_edges WHERE owner_node_id = ? " + "ORDER BY source_id, ordinal, relation, target_id", + (owner_node_id,), + ).fetchall() + nodes = [ + _logic_node_dict(row, owner_row=owner_row, owner_node_id=owner_node_id) + for row in node_rows + ] + entry = next( + (cast(str, node["node_id"]) for node in nodes if node["logic_kind"] == "entry"), + cast(str, nodes[0]["node_id"]) if nodes else owner_node_id, + ) + edges = [ + { + "source_id": row["source_id"], + "relation": row["relation"], + "target_id": row["target_id"], + "label": row["label"], + "ordinal": row["ordinal"], + "reversed": False, + } + for row in edge_rows + ] + return self._result( + root=entry, + logic=True, + available=True, + source_id=owner["source_id"], + owner=_node_dict(owner_row, include_content=False), + nodes=nodes, + edges=edges, + snapshot=True, + ) + def lineage(self, node_id: str, *, limit: int) -> dict[str, object]: """Return bounded semantic flow paths terminating at ``node_id``. @@ -941,6 +1083,9 @@ class VisualizationRunner: elif parsed.path == f"{prefix}/api/web": self._touch_lease() payload = self._web(reader, params) + elif parsed.path == f"{prefix}/api/logic": + self._touch_lease() + payload = self._logic(reader, params) else: self._respond_error( handler, @@ -1056,6 +1201,16 @@ class VisualizationRunner: ) return reader.web(node_id, depth=depth, limit=limit) + @staticmethod + def _logic( + reader: VisualizationIndexSnapshot, + params: dict[str, list[str]], + ) -> dict[str, object]: + owner_node_id = _one(params, "id").strip() + if not owner_node_id: + raise DocForgeError("missing_node", "One exact owner node ID is required") + return reader.logic(owner_node_id) + def _filter( self, reader: VisualizationIndexSnapshot, @@ -1546,6 +1701,29 @@ def _node_dict(row: sqlite3.Row, *, include_content: bool = True) -> dict[str, o return result +def _logic_node_dict( + row: sqlite3.Row, + *, + owner_row: sqlite3.Row, + owner_node_id: str, +) -> dict[str, object]: + kind = cast(str, row["kind"]) + return { + "node_id": row["logic_id"], + "title": row["label"], + "family": "logic", + "authority": "derived", + "status": "current", + "tags": ("logic", kind), + "summary": f"{kind.replace('_', ' ').title()} in {owner_row['title']}.", + "source_path": owner_row["source_path"], + "source_anchor": row["source_anchor"], + "content_hash": "", + "logic_kind": kind, + "logic_owner_id": owner_node_id, + } + + def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list[dict[str, object]]: allowed = { ("nodes", "family"), diff --git a/tests/test_adapter_contract.py b/tests/test_adapter_contract.py index 69815ca..18772b8 100644 --- a/tests/test_adapter_contract.py +++ b/tests/test_adapter_contract.py @@ -41,6 +41,7 @@ from docforge.models import ( RenderConfig, RenderView, ) +from docforge.visualization import VisualizationIndexSnapshot class Loader: @@ -302,6 +303,16 @@ class AdapterContractTests(unittest.TestCase): first = index.build() self.assertEqual(2, first["build"]["reparsed_sources"]) self.assertEqual(0, first["build"]["cache_hits"]) + self.assertEqual(1, first["logic_projection_count"]) + logic = index.get_logic("guide.workflow") + self.assertTrue(logic["available"]) + self.assertEqual("guide.workflow", logic["projection"]["owner_node_id"]) + self.assertEqual(2, len(logic["projection"]["nodes"])) + self.assertFalse(index.get_logic("guide.foundation")["available"]) + visual_logic = VisualizationIndexSnapshot(index, index.check()).logic("guide.workflow") + self.assertTrue(visual_logic["available"]) + self.assertEqual("entry", visual_logic["root"]) + self.assertEqual("return", visual_logic["edges"][0]["relation"]) loader.extract_calls.clear() cache_path = root / ".cache" / "incremental" / "extractions.json" cache_modified = cache_path.stat().st_mtime_ns diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 7f48b26..bca685a 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -86,6 +86,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): ("docforge_project_info", {}), ("docforge_get_contract", {}), ("docforge_get_node", {"node_id": "guide.workflow"}), + ("docforge_get_logic", {"owner_node_id": "guide.workflow"}), ("docforge_search", {"query": "canonical nodes", "limit": 5}), ("docforge_filter_nodes", {"family": "proof", "tag": "validation"}), ("docforge_backlinks", {"node_id": "guide.workflow"}), @@ -126,18 +127,19 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): self.assertIn("arbitrary_renderer_execution", contract["excluded_operations"]) self.assertFalse(contract["isolated_changeset_writes_allowed"]) self.assertFalse(contract["proposal_access"]["enabled"]) - self.assertTrue(results[10].structuredContent["configured"]) - self.assertEqual("stale", results[10].structuredContent["state"]) - visualization = results[11].structuredContent["visualization"] + self.assertFalse(results[3].structuredContent["available"]) + self.assertTrue(results[11].structuredContent["configured"]) + self.assertEqual("stale", results[11].structuredContent["state"]) + visualization = results[12].structuredContent["visualization"] self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["project_bound"]) - self.assertEqual("graph-browser@15", visualization["template"]) + self.assertEqual("graph-browser@16", 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:")) - self.assertEqual("stopped", results[12].structuredContent["state"]) - self.assertEqual("not_running", results[13].structuredContent["state"]) - context = results[8].structuredContent + self.assertEqual("stopped", results[13].structuredContent["state"]) + self.assertEqual("not_running", results[14].structuredContent["state"]) + context = results[9].structuredContent self.assertLessEqual(context["estimated_tokens"], 180) self.assertTrue(context["omissions"]) diff --git a/tests/test_python_logic.py b/tests/test_python_logic.py new file mode 100644 index 0000000..6cc70c8 --- /dev/null +++ b/tests/test_python_logic.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import unittest + +from docforge.python_logic import PythonLogicOwner, analyze_python_source + + +class PythonLogicTests(unittest.TestCase): + def projection(self, source: str, qualified_name: str, line: int = 1): + return analyze_python_source( + source, + source_id="source.example", + owners=(PythonLogicOwner("py.symbol.example", qualified_name, line),), + filename="example.py", + )[0] + + def test_boolean_short_circuit_branches_and_terminals(self) -> None: + projection = self.projection( + """\ +def decide(enabled, cached, stale): + if enabled and (cached is None or stale): + return "fetch" + raise RuntimeError("disabled") +""", + "decide", + ) + kinds = [node.kind for node in projection.nodes] + labels = [node.label for node in projection.nodes] + edge_labels = [edge.label for edge in projection.edges] + + self.assertEqual(1, kinds.count("entry")) + self.assertEqual(1, kinds.count("exit")) + self.assertEqual(3, kinds.count("condition")) + self.assertIn("enabled", labels) + self.assertIn("cached is None", labels) + self.assertIn("stale", labels) + self.assertIn("TRUE", edge_labels) + self.assertIn("FALSE", edge_labels) + self.assertIn("RETURN", edge_labels) + self.assertIn("RAISE", edge_labels) + + def test_loops_match_try_and_control_transfers_are_explicit(self) -> None: + projection = self.projection( + """\ +def process(items, mode): + for item in items: + if item.skip: + continue + if item.stop: + break + consume(item) + else: + finish() + match mode: + case "safe": + value = safe() + case _: + value = fallback() + try: + return value + except ValueError: + raise + finally: + cleanup() +""", + "process", + ) + kinds = {node.kind for node in projection.nodes} + relations = {edge.relation for edge in projection.edges} + labels = {edge.label for edge in projection.edges} + + self.assertTrue( + {"loop", "continue", "break", "case", "try", "except", "finally"}.issubset(kinds) + ) + self.assertTrue( + {"loop", "continue", "break", "case", "exception", "return", "raise"}.issubset( + relations + ) + ) + self.assertIn("EXHAUSTED", labels) + self.assertIn("NEXT ITEM", labels) + self.assertIn("NEXT CASE", labels) + + def test_class_methods_nested_functions_and_async_functions_use_explicit_owners(self) -> None: + source = """\ +class Worker: + async def run(self): + async with self.session(): + await self.step() + + if self.enabled: + def nested(): + return True + + return nested() +""" + projections = analyze_python_source( + source, + source_id="source.worker", + owners=( + PythonLogicOwner("py.symbol.worker.run", "Worker.run", 2), + PythonLogicOwner("py.symbol.worker.nested", "Worker.run.nested", 7), + ), + filename="worker.py", + ) + + self.assertEqual( + ("py.symbol.worker.nested", "py.symbol.worker.run"), + tuple(projection.owner_node_id for projection in projections), + ) + run = next(item for item in projections if item.owner_node_id.endswith(".run")) + self.assertIn("action", {node.kind for node in run.nodes}) + self.assertIn("call", {node.kind for node in run.nodes}) + + def test_projection_is_deterministic(self) -> None: + source = """\ +def choose(first, second): + return first if first else second +""" + first = self.projection(source, "choose") + second = self.projection(source, "choose") + self.assertEqual(first, second) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 4e08140..7dc8cd2 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -237,6 +237,7 @@ The test verifies the default behavior. def test_browser_contains_hiding_source_navigation_and_scrollable_inspector(self) -> None: self.assertIn('id="restore-hidden"', _GRAPH_BROWSER_HTML) self.assertIn('id="view-web"', _GRAPH_BROWSER_HTML) + self.assertIn('id="view-logic"', _GRAPH_BROWSER_HTML) self.assertIn('id="open-node-source"', _GRAPH_BROWSER_HTML) self.assertIn('id="hide-node"', _GRAPH_BROWSER_HTML) self.assertIn('id="source-dialog"', _GRAPH_BROWSER_HTML) @@ -455,6 +456,7 @@ if (pruned.prunedCount !== 2) fail("pruned node count"); self.assertIn('id="view-nodes"', html) self.assertIn('id="view-flow"', html) self.assertIn('id="view-web"', html) + self.assertIn('id="view-logic"', html) self.assertIn('id="neighborhood-sections"', html) self.assertIn('id="relationship-key"', html) self.assertIn('id="relationship-key-list"', html) @@ -595,6 +597,15 @@ if (pruned.prunedCount !== 2) fail("pruned node count"); self.assertEqual("guide.workflow", web["root"]) self.assertEqual(0, web["hops"]["guide.workflow"]) + logic_query = urllib.parse.urlencode({"id": "guide.workflow"}) + with urllib.request.urlopen( + f"{base}api/logic?{logic_query}", timeout=2 + ) as response: + logic = json.load(response) + self.assertTrue(logic["logic"]) + self.assertFalse(logic["available"]) + self.assertEqual("guide.workflow", logic["root"]) + 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) diff --git a/uv.lock b/uv.lock index 396f46f..d0366a7 100644 --- a/uv.lock +++ b/uv.lock @@ -206,7 +206,7 @@ wheels = [ [[package]] name = "docforge" -version = "1.1.0.dev0" +version = "1.2.0.dev0" source = { editable = "." } dependencies = [ { name = "markdown-it-py" },