From 9161889492360b438527e08d0105cf35bbbc5d8f Mon Sep 17 00:00:00 2001 From: Andraxion Date: Sat, 25 Jul 2026 22:29:15 -0400 Subject: [PATCH] Add multi-language logic exploration --- README.md | 9 +- SLICE_HISTORY.md | 23 +- docs/CONTRACT.md | 21 +- docs/INCREMENTAL_INDEXING.md | 15 +- docs/MCP_CONTRACT.md | 8 +- docs/USER_MANUAL.md | 33 +- pyproject.toml | 8 +- src/docforge/assets/graph.css | 41 +- src/docforge/assets/graph.html | 28 ++ src/docforge/assets/graph.js | 295 ++++++++++- src/docforge/python_logic.py | 34 +- src/docforge/treesitter_logic.py | 831 +++++++++++++++++++++++++++++++ src/docforge/viewer_manager.py | 9 +- src/docforge/visualization.py | 118 ++++- tests/test_mcp_server.py | 2 +- tests/test_treesitter_logic.py | 154 ++++++ tests/test_visualization.py | 20 + uv.lock | 66 +++ 18 files changed, 1639 insertions(+), 76 deletions(-) create mode 100644 src/docforge/treesitter_logic.py create mode 100644 tests/test_treesitter_logic.py diff --git a/README.md b/README.md index 70ccec2..5a00762 100644 --- a/README.md +++ b/README.md @@ -47,13 +47,18 @@ fourth function-scoped view only when requested: 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 +- **Logic** shows the possible static control paths inside a focused Python, JavaScript, or C++ + function or method. Entry, decisions, actions, loops, convergence points, 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. +The left browser panel can combine text, family, node-kind, language, and capability filters. +Quick presets expose Logic-ready nodes, Python callables, tests, routes, and documentation without +requiring users to know stable IDs. Selecting a canvas node emphasizes its directly connected +neighbors and edges while muting unrelated paths. **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 diff --git a/SLICE_HISTORY.md b/SLICE_HISTORY.md index cba89a9..110b4d7 100644 --- a/SLICE_HISTORY.md +++ b/SLICE_HISTORY.md @@ -1,5 +1,24 @@ # Completed slices +## Dev-Rewrite multi-language Logic and traceable browser + +### Changed + +- Added pinned Tree-sitter-backed JavaScript and C++ analyzers behind the existing + language-neutral `LogicProjection` boundary. +- Replaced ambiguous merge terminology with decision, case, loop-exit, and exception convergence. +- Added composable text, family, node-kind, language, and capability filters plus common presets. +- Added direct-neighbor and incident-edge highlighting when a canvas node is selected. +- Increased Logic layer clearance and vertical spacing, with routed edge lanes for branches, + returns, and loop-back paths. + +### Verification + +- Tests cover JavaScript and C++ functions, methods, branches, short-circuit booleans, loops, + cases, exceptions, and returns alongside Python behavior. +- Visualization tests cover filter facets, capability filtering, trace controls, template + identity, and browser asset validity. + ## Dev-Rewrite function-scoped Logic ### Changed @@ -8,8 +27,8 @@ - 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 semantic Entry, Decision, Action, Control, Convergence, 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 diff --git a/docs/CONTRACT.md b/docs/CONTRACT.md index 2d7db4e..71a5695 100644 --- a/docs/CONTRACT.md +++ b/docs/CONTRACT.md @@ -106,11 +106,12 @@ and no-referrer policy. The built-in template uses only same-origin JSON endpoin overview, bounded search, exact descriptor-category filtering, exact node content, bounded 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. +Descriptor filtering accepts only family, authority, status, or tag plus one exact value. +Search filtering accepts only family, indexed kind or callable, indexed language tag, and the +fixed `logic` or `source` capability. There is no write endpoint, arbitrary query endpoint, static +filesystem handler, external asset, or project-selection control. -The `graph-browser@16` template provides mouse-wheel zoom centered on the pointer, left-button drag +The `graph-browser@17` 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 @@ -130,6 +131,10 @@ inspectors. This display shortening is presentation-only and never changes index Left-clicking or pressing Enter on a graph node opens a compact descriptor card containing the validated metadata and content previously shown in the details panel. Its family, authority, status, and tag pills are buttons that replace the left result list with exact matching nodes. +The left result panel also exposes composable family, node-kind, language, and capability filters +plus fixed convenience presets. These filters are bounded read-only queries over indexed +attributes and stored Logic ownership. Selecting a canvas node emphasizes only its incident edges +and directly connected nodes; unrelated visible paths are muted but remain present. Right-clicking or pressing Shift+Enter opens the complete inspector. Inspection does not replace the current neighborhood or reset the viewport. Both dialogs support Escape, explicit close controls, and backdrop dismissal. Loading the inspected node as the new root requires the separate @@ -153,7 +158,8 @@ validated snapshot; they do not add or change project relationships. 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. +control, convergence, 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. @@ -171,6 +177,11 @@ distinct palettes and navigation sections. An undirected shortest-hop calculatio distance rings; Flow and Web use left-to-right distance layers with the destination on the right. Each role palette darkens progressively by distance, capped at fifty percent. +Logic uses a layered left-to-right layout with explicit horizontal clearance and vertical +separation between siblings. Control-flow edges use routed curves and distinct lanes, including +raised return and loop-back routes, to avoid drawing one path directly over another whenever the +bounded topology permits. + Each invocation creates or reuses one worker through the separately supervised, per-user viewer manager. The manager is outside the short-lived MCP transport and owns all child workers as one OS service unit. It accepts only authenticated loopback requests and a validated immutable snapshot. diff --git a/docs/INCREMENTAL_INDEXING.md b/docs/INCREMENTAL_INDEXING.md index 6bd0c87..0bfbfda 100644 --- a/docs/INCREMENTAL_INDEXING.md +++ b/docs/INCREMENTAL_INDEXING.md @@ -126,15 +126,20 @@ anchor node's expected content hash. `LogicProjection` stores control flow separately from the primary architecture graph. It is owned by one function or method node and one source extraction. -Logic nodes can represent entries, conditions, basic blocks, calls, merges, loops, returns, and -raises. Logic edges retain relation, display label, and deterministic ordinal. Adapters may leave +Logic nodes can represent entries, conditions, basic blocks, calls, convergence points, loops, +returns, and raises. Logic edges retain relation, display label, and deterministic ordinal. +Adapters may leave 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. 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. +request one function-scoped projection on demand. The built-in analyzers cover Python, +JavaScript, and C++. Python uses the standard-library AST. JavaScript and C++ share pinned +Tree-sitter infrastructure with thin language-aware control-flow profiles. Parsers run only while +extracting a changed source contribution; ordinary graph reads do not load or execute them. A +grammar alone supplies syntax, not control-flow meaning, so each new language still needs a small +semantic profile for its branch, loop, case, exception, and termination constructs. All analyzers +report possible static paths; they do not claim runtime branch outcomes. ## Full rebuilds diff --git a/docs/MCP_CONTRACT.md b/docs/MCP_CONTRACT.md index d204079..1118f40 100644 --- a/docs/MCP_CONTRACT.md +++ b/docs/MCP_CONTRACT.md @@ -86,13 +86,14 @@ only through the explicit local CLI integration command. ## Visualization boundary -`docforge_visualize` starts the fixed built-in `graph-browser@16` template against the currently +`docforge_visualize` starts the fixed built-in `graph-browser@17` 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, +search, exact family/authority/status/tag filtering, composable node-kind/language/capability +filtering, node-neighborhood JSON, semantic Flow, 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 @@ -100,7 +101,8 @@ 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 drag pan, explicit zoom buttons, reset, and Space-to-center selection never request or mutate -project data. Left activation visibly selects the node and opens a compact descriptor card. +project data. Left activation visibly selects the node, highlights its incident edges and direct +neighbors, mutes unrelated visible paths, and opens a compact descriptor card. Right-click opens the full inspector. Descriptor-pill activation fills the fixed left panel with an exact bounded category result set. The fixed right panel contains neighborhood navigation. Replacing the current root requires an explicit Explore neighborhood action. Users may hide diff --git a/docs/USER_MANUAL.md b/docs/USER_MANUAL.md index 2edd4e5..ea95d29 100644 --- a/docs/USER_MANUAL.md +++ b/docs/USER_MANUAL.md @@ -309,17 +309,46 @@ graph. The view presents: assertions. - **Action** cards for executable statement blocks and calls. - **Control** cards for loops, `break`, and `continue`. -- **Merge** cards where alternate paths converge. +- **Convergence** cards where alternate paths rejoin, including decision, case, loop-exit, and + exception convergence. - **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. +The built-in analyzers cover Python, JavaScript, and C++. Python uses the standard-library AST. +JavaScript and C++ use pinned Tree-sitter grammars behind the same language-neutral +`LogicProjection` contract. Tree-sitter handles concrete syntax; DocForge keeps a thin +language-specific control-flow profile for constructs such as conditions, loops, cases, +exceptions, returns, and short-circuit operators. Adding a language therefore requires a grammar +and a semantic profile, not a new visualization or database design. + 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. +### Finding the right node + +The left panel combines independent filters rather than forcing users to scan the complete node +list: + +- **Text** searches indexed titles, summaries, and content. +- **Family** selects the project-defined family. +- **Node type** selects callables or an exact indexed kind such as function, method, class, route, + test, module, or document. +- **Language** selects an indexed language tag such as Python, JavaScript, or C++. +- **Capability** selects nodes with source navigation or an available Logic projection. + +Quick presets select common combinations for Logic-ready nodes, Python callables, tests, routes, +and documentation. Filters compose, so `JavaScript` plus `Logic available` lists only JavaScript +functions that can open Logic. Result cards show the readable leaf name, kind, language, path, and +source anchor. Full identities remain in the tooltip and inspector. + +Selecting any canvas node highlights its directly connected nodes and the exact edges between +them. Other nodes and edges remain visible at reduced opacity. This local trace works in Nodes, +Flow, Web, and Logic without changing the root or querying a different graph. + ### Reading graph cards The canvas presents nodes as compact semantic cards rather than anonymous circles: @@ -635,7 +664,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@16` template. +still open, stop and reopen the visualization so it loads the current `graph-browser@17` template. ### Render output is stale diff --git a/pyproject.toml b/pyproject.toml index 519754c..487c8a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,13 @@ readme = "README.md" requires-python = ">=3.12" license = { text = "MIT" } authors = [{ name = "Worldforge contributors" }] -dependencies = ["markdown-it-py>=4.2,<5", "mcp>=1.28,<2"] +dependencies = [ + "markdown-it-py>=4.2,<5", + "mcp>=1.28,<2", + "tree-sitter>=0.25,<0.26", + "tree-sitter-cpp>=0.23,<0.24", + "tree-sitter-javascript>=0.25,<0.26", +] [dependency-groups] dev = ["pytest>=9.1,<10", "ruff>=0.15,<1"] diff --git a/src/docforge/assets/graph.css b/src/docforge/assets/graph.css index 8f6bf0c..84672b6 100644 --- a/src/docforge/assets/graph.css +++ b/src/docforge/assets/graph.css @@ -39,10 +39,18 @@ 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); } +.filter-presets { display: flex; flex-wrap: wrap; gap: 5px; } +.filter-presets button { + border: 1px solid var(--line); border-radius: 999px; padding: 4px 8px; + background: #0b1724; color: var(--muted); font-size: 10px; font-weight: 700; +} .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; } +.filter-presets button:hover, .filter-presets button:focus-visible { + border-color: var(--accent); color: var(--text); outline: none; +} .view-switch button[aria-pressed="true"] { color: var(--text); } .view-switch button:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; } .stats { display: flex; flex: 0 0 auto; gap: 14px; color: var(--muted); } @@ -71,6 +79,9 @@ aside { min-height: 0; overflow: hidden; padding: 16px; background: var(--panel) .panel-resizer.resizing::after { background: var(--accent); } .panel-resizer:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; } form { display: grid; flex: 0 0 auto; gap: 8px; } +form > label, .filter-grid label { + display: grid; gap: 6px; color: var(--muted); font-size: 11px; font-weight: 700; +} input, select { width: 100%; border: 1px solid var(--line); border-radius: 8px; padding: 9px 10px; background: var(--panel-2); color: var(--text); @@ -79,6 +90,9 @@ input:focus-visible, select:focus-visible { border-color: var(--accent); outline: 2px solid var(--accent); outline-offset: 1px; } .search-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; } +.filter-grid { + display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 8px; +} .button { border: 1px solid #277fa0; border-radius: 8px; padding: 8px 12px; background: #12384a; color: var(--text); @@ -162,8 +176,16 @@ input:focus-visible, select:focus-visible { padding: 9px; background: var(--panel-2); color: var(--text); } .result:hover, .result:focus-visible { border-color: var(--accent); outline: none; } -.result strong, .result span { display: block; overflow: hidden; text-overflow: ellipsis; } -.result span { color: var(--muted); font-size: 12px; white-space: nowrap; } +.result strong, .result span { display: block; overflow-wrap: anywhere; } +.result > span { color: var(--muted); font-size: 11px; } +.result-badges { + display: flex !important; flex-wrap: wrap; gap: 4px; margin: 5px 0; +} +.result-badges small { + border: 1px solid #31526d; border-radius: 999px; padding: 1px 6px; + background: #0a1724; color: #b7c9da; font-size: 9px; font-weight: 750; + letter-spacing: .04em; text-transform: uppercase; +} .canvas { position: relative; min-width: 0; min-height: 0; overflow: hidden; } svg { width: 100%; height: 100%; background: radial-gradient(circle at 52% 46%, rgba(26, 65, 89, .58) 0, rgba(10, 28, 45, .46) 34%, @@ -240,13 +262,24 @@ svg { width: 100%; height: 100%; background: .relationship-key-empty { margin: 2px 0; color: var(--muted); font-size: 11px; } .relationship-edge { fill: none; stroke-opacity: .74; stroke-width: 1.7; - vector-effect: non-scaling-stroke; + vector-effect: non-scaling-stroke; transition: opacity .16s, stroke-width .16s, filter .16s; } .edge-label { font-size: 9px; font-weight: 700; letter-spacing: .015em; pointer-events: none; paint-order: stroke; stroke: #07101a; stroke-width: 4px; stroke-linejoin: round; + transition: opacity .16s; } -.node { cursor: pointer; } +.relationship-edge.trace-connected { + stroke-opacity: 1; stroke-width: 3; + filter: drop-shadow(0 0 7px currentcolor); +} +.relationship-edge.trace-muted, .edge-label.trace-muted { opacity: .13; } +.edge-label.trace-connected { opacity: 1; font-size: 10px; } +.node { cursor: pointer; transition: opacity .16s, filter .16s; } +.node.trace-connected:not(.selected) { + filter: drop-shadow(0 0 8px rgba(165, 243, 252, .28)); +} +.node.trace-muted { opacity: .24; } .node:focus { outline: none; } .node .node-surface { stroke-width: 1.35; vector-effect: non-scaling-stroke; diff --git a/src/docforge/assets/graph.html b/src/docforge/assets/graph.html index c857ca7..be58d3d 100644 --- a/src/docforge/assets/graph.html +++ b/src/docforge/assets/graph.html @@ -35,6 +35,34 @@ +
+ + +
+ + +
+ + + + + +
All nodes diff --git a/src/docforge/assets/graph.js b/src/docforge/assets/graph.js index 556dc37..8cf3a15 100644 --- a/src/docforge/assets/graph.js +++ b/src/docforge/assets/graph.js @@ -23,6 +23,35 @@ const state = { dialogDrag: null, leaseTimer: null, }; +const nodeKindOptions = Object.freeze([ + ["function", "Function"], + ["method", "Method"], + ["class", "Class"], + ["module", "Module"], + ["package", "Package"], + ["route", "Route"], + ["command", "Command"], + ["service", "Service"], + ["plugin", "Plugin"], + ["test", "Test"], + ["table", "Table"], + ["view", "View"], + ["document", "Document"], + ["manual", "Manual"], + ["section", "Section"], +]); +const languageOptions = Object.freeze([ + ["python", "Python"], + ["javascript", "JavaScript"], + ["typescript", "TypeScript"], + ["cpp", "C++"], + ["c", "C"], + ["csharp", "C#"], + ["rust", "Rust"], + ["java", "Java"], + ["go", "Go"], + ["sql", "SQL"], +]); const relationStyles = Object.freeze({ contains: { family: "Structure", color: "#60a5fa", dash: "", marker: "diamond-arrow", @@ -183,8 +212,8 @@ const contributionStyles = Object.freeze({ "logic-control": { label: "Control", section: "Loops & exception handling", color: "#c084fc", fill: "#302044", }, - "logic-merge": { - label: "Merge", section: "Branch convergence", color: "#94a3b8", fill: "#252d39", + "logic-convergence": { + label: "Convergence", section: "Control paths reunite", color: "#94a3b8", fill: "#252d39", }, "logic-terminal": { label: "Terminal", section: "Returns, raises & exits", color: "#fb7185", fill: "#41202a", @@ -194,7 +223,7 @@ 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", + "logic-convergence", "logic-terminal", ]); const compositionRelations = new Set(["contains", "defines", "defined_in"]); const behaviorRelations = new Set(["inherits", "implemented_by"]); @@ -486,8 +515,31 @@ function selectNode(nodeId) { group.classList.toggle("selected", selected); group.setAttribute("aria-pressed", String(selected)); } + applyTraceHighlight(nodeId); return true; } +function applyTraceHighlight(nodeId) { + const graph = $("graph"); + const connected = new Set([nodeId]); + for (const edge of graph.querySelectorAll(".relationship-edge")) { + const direct = edge.dataset.sourceId === nodeId || edge.dataset.targetId === nodeId; + edge.classList.toggle("trace-connected", direct); + edge.classList.toggle("trace-muted", !direct); + if (direct) { + connected.add(edge.dataset.sourceId); + connected.add(edge.dataset.targetId); + } + } + for (const label of graph.querySelectorAll(".edge-label")) { + const direct = label.dataset.sourceId === nodeId || label.dataset.targetId === nodeId; + label.classList.toggle("trace-connected", direct); + label.classList.toggle("trace-muted", !direct); + } + for (const group of graph.querySelectorAll(".node")) { + group.classList.toggle("trace-connected", connected.has(group.dataset.nodeId)); + group.classList.toggle("trace-muted", !connected.has(group.dataset.nodeId)); + } +} function centerSelectedNode() { const point = state.positions.get(state.selectedNode); if (!point) return false; @@ -562,6 +614,39 @@ function renderOverview(data) { option.textContent = `${item.value} (${item.count})`; family.append(option); } + const tagCounts = new Map((data.tags || []).map( + (item) => [String(item.value), Number(item.count)], + )); + const kind = $("kind"); + const callableCount = ["function", "method", "nested-function"] + .reduce((total, tag) => total + (tagCounts.get(tag) || 0), 0); + kind.options[1].textContent = `Callable functions & methods (${callableCount})`; + for (const [value, label] of nodeKindOptions) { + const count = tagCounts.get(value) || 0; + if (!count) continue; + const option = document.createElement("option"); + option.value = value; + option.textContent = `${label} (${count})`; + kind.append(option); + } + const language = $("language"); + for (const [value, label] of languageOptions) { + const count = tagCounts.get(value) || 0; + if (!count) continue; + const option = document.createElement("option"); + option.value = value; + option.textContent = `${label} (${count})`; + language.append(option); + } + const capabilityCounts = new Map((data.capabilities || []).map( + (item) => [String(item.value), Number(item.count)], + )); + for (const option of $("capability").options) { + const count = capabilityCounts.get(option.value); + if (option.value && count !== undefined) { + option.textContent = `${option.textContent} (${count})`; + } + } } function renderResults(items) { const results = $("results"); @@ -578,12 +663,26 @@ function renderResults(items) { button.type = "button"; button.className = "result"; const title = document.createElement("strong"); - title.textContent = item.title; + title.textContent = nodeDisplayName(item); + const badges = document.createElement("span"); + badges.className = "result-badges"; + const tags = new Set(item.tags || []); + const kind = nodeKindLabel(item); + const language = languageOptions.find(([value]) => tags.has(value))?.[1]; + for (const label of [kind, language]) { + if (!label) continue; + const badge = document.createElement("small"); + badge.textContent = label; + badges.append(badge); + } const id = document.createElement("span"); - id.textContent = item.node_id; + id.textContent = item.source_anchor + ? `${item.source_path} · ${item.source_anchor}` + : item.source_path; const family = document.createElement("span"); - family.textContent = `${item.family} · ${item.source_path}`; - button.append(title, id, family); + family.textContent = item.family; + button.title = `${item.title}\n${item.node_id}`; + button.append(title, badges, id, family); button.addEventListener("click", () => loadNode(item.node_id)); results.append(button); } @@ -843,7 +942,7 @@ function nodeContributionCategory(nodeId, data, topology) { if (["loop", "try", "except", "finally", "break", "continue"].includes(kind)) { return "logic-control"; } - if (kind === "merge") return "logic-merge"; + if (["merge", "convergence"].includes(kind)) return "logic-convergence"; if (["return", "raise", "exit"].includes(kind)) return "logic-terminal"; return "logic-action"; } @@ -946,11 +1045,65 @@ 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)}); +function layoutLogic(nodes, rootId, topology, edges, sizes = nodeSizeMap(nodes, rootId)) { + const layers = new Map(); + for (const node of nodes) { + const hop = topology.get(node.node_id).hop; + if (!layers.has(hop)) layers.set(hop, []); + layers.get(hop).push(node); + } + const positions = new Map(); + const layerWidths = new Map([...layers].map(([hop, layer]) => [ + hop, + Math.max(...layer.map((node) => sizes.get(node.node_id).width)), + ])); + const layerX = new Map([[0, 0]]); + for (const hop of [...layers.keys()].sort((a, b) => a - b).filter((value) => value > 0)) { + const previous = layerX.get(hop - 1) || 0; + layerX.set( + hop, + previous + (layerWidths.get(hop - 1) || 188) / 2 + + (layerWidths.get(hop) || 188) / 2 + 190, + ); + } + const relationRank = new Map([ + ["when_true", 0], ["case", 1], ["next", 2], ["when_false", 3], + ["exception", 4], ["loop", 5], + ]); + for (const [hop, layer] of [...layers.entries()].sort((a, b) => a[0] - b[0])) { + layer.sort((first, second) => { + const firstIncoming = edges.filter((edge) => edge.target_id === first.node_id); + const secondIncoming = edges.filter((edge) => edge.target_id === second.node_id); + const parentY = (incoming) => { + const points = incoming + .map((edge) => positions.get(edge.source_id)?.y) + .filter((value) => value !== undefined); + return points.length + ? points.reduce((total, value) => total + value, 0) / points.length + : 0; + }; + const relation = (incoming) => Math.min( + ...incoming.map((edge) => relationRank.get(edge.relation) ?? 20), + 20, + ); + return parentY(firstIncoming) - parentY(secondIncoming) + || relation(firstIncoming) - relation(secondIncoming) + || first.node_id.localeCompare(second.node_id); + }); + const verticalGap = 96; + const layerHeight = layer.reduce( + (total, node) => total + sizes.get(node.node_id).height + verticalGap, + -verticalGap, + ); + let cursor = -layerHeight / 2; + for (const node of layer) { + const size = sizes.get(node.node_id); + positions.set(node.node_id, { + x: layerX.get(hop) || 0, + y: cursor + size.height / 2, + }); + cursor += size.height + verticalGap; + } } return positions; } @@ -1081,6 +1234,30 @@ function edgeEndpoints(source, target, sourceSize, targetSize) { y2: target.y - unitY * targetOffset, }; } +function logicEdgeGeometry(points, lane) { + const {x1, y1, x2, y2} = points; + const deltaX = x2 - x1; + if (deltaX > 40) { + const bend = Math.max(70, deltaX * .42); + const controlY = lane * 14; + return { + path: `M ${x1} ${y1} C ${x1 + bend} ${y1 + controlY}, ` + + `${x2 - bend} ${y2 + controlY}, ${x2} ${y2}`, + label: { + x: (x1 + x2) / 2, + y: (y1 + y2) / 2 + controlY * .75 - 8, + }, + }; + } + const direction = lane % 2 === 0 ? -1 : 1; + const archY = Math.min(y1, y2) + direction * (130 + Math.abs(lane) * 22); + const reach = Math.max(90, Math.abs(deltaX) * .32); + return { + path: `M ${x1} ${y1} C ${x1 + reach} ${archY}, ` + + `${x2 - reach} ${archY}, ${x2} ${y2}`, + label: {x: (x1 + x2) / 2, y: archY - 8}, + }; +} function topologyRoleLabel(category) { const style = contributionStyles[category] || contributionStyles.related; return category === "focus" ? `${state.mode} focus` : `${style.label} contributor`; @@ -1158,7 +1335,7 @@ function renderGraph(data, preserveSelection = false) { const categories = nodeCategoryMap(view, topology); const sizes = nodeSizeMap(view.nodes, view.root); const positions = state.mode === "logic" - ? layoutLogic(view.nodes, view.root, topology, sizes) + ? layoutLogic(view.nodes, view.root, topology, view.edges, sizes) : state.mode !== "nodes" ? layoutFlow(view.nodes, view.root, topology, sizes) : layoutNodes(view.nodes, view.root, topology, sizes); @@ -1173,6 +1350,11 @@ function renderGraph(data, preserveSelection = false) { } const edgeLayer = svgElement("g"); const nodeLayer = svgElement("g"); + const outgoing = new Map(); + for (const edge of view.edges) { + if (!outgoing.has(edge.source_id)) outgoing.set(edge.source_id, []); + outgoing.get(edge.source_id).push(edge); + } for (const edge of view.edges) { const source = positions.get(edge.source_id); const target = positions.get(edge.target_id); @@ -1184,21 +1366,36 @@ function renderGraph(data, preserveSelection = false) { sizes.get(edge.source_id), sizes.get(edge.target_id), ); - const line = svgElement("line", { - ...points, + const siblings = outgoing.get(edge.source_id); + const lane = siblings.indexOf(edge) - (siblings.length - 1) / 2; + const geometry = state.mode === "logic" + ? logicEdgeGeometry(points, lane) + : { + path: `M ${points.x1} ${points.y1} L ${points.x2} ${points.y2}`, + label: { + x: (points.x1 + points.x2) / 2, + y: (points.y1 + points.y2) / 2 - 5, + }, + }; + const line = svgElement("path", { + d: geometry.path, class: "relationship-edge", stroke: style.color, "marker-end": `url(#${relationMarkerId(edge.relation)})`, "data-relation": edge.relation, + "data-source-id": edge.source_id, + "data-target-id": edge.target_id, }); if (style.dash) line.setAttribute("stroke-dasharray", style.dash); edgeLayer.append(line); const label = svgElement("text", { - x: (points.x1 + points.x2) / 2, - y: (points.y1 + points.y2) / 2 - 5, + x: geometry.label.x, + y: geometry.label.y, class: "edge-label", fill: style.color, "text-anchor": "middle", + "data-source-id": edge.source_id, + "data-target-id": edge.target_id, }); label.textContent = edge.label || relationLabel(edge.relation, edge.reversed); edgeLayer.append(label); @@ -1291,6 +1488,7 @@ function renderGraph(data, preserveSelection = false) { nodeLayer.append(group); } svg.append(definitions, edgeLayer, nodeLayer); + applyTraceHighlight(state.selectedNode); } function hideNode(nodeId) { if (!state.graph || nodeId === state.root) { @@ -1512,16 +1710,25 @@ async function search() { const params = new URLSearchParams({ q: $("search").value.trim(), family: $("family").value, + kind: $("kind").value, + language: $("language").value, + capability: $("capability").value, limit: String(state.searchLimit), }); + const filtersActive = [ + $("search").value.trim(), + $("family").value, + $("kind").value, + $("language").value, + $("capability").value, + ].some(Boolean); try { setStatus("Searching validated index…"); const data = await api(`search?${params}`); renderResults(data.results || []); setResultsContext( - $("search").value.trim() || $("family").value - ? `${data.count} search results` - : "All nodes", + filtersActive ? `${data.count} filtered results` : "All nodes", + filtersActive, ); setStatus(`${data.count} matching node${data.count === 1 ? "" : "s"}`); } catch (error) { @@ -1538,8 +1745,12 @@ async function filterByDescriptor(category, value) { closeNodeCard(); setStatus(`Filtering ${category} ${value}…`); const data = await api(`filter?${params}`); - $("search").value = ""; - $("family").value = category === "family" ? value : ""; + clearSearchFilters(); + if (category === "family") $("family").value = value; + if (category === "tag") { + if (nodeKindOptions.some(([tag]) => tag === value)) $("kind").value = value; + if (languageOptions.some(([tag]) => tag === value)) $("language").value = value; + } renderResults(data.results || []); setResultsContext(`${category}: ${value} (${data.total})`, true); const suffix = data.truncated ? ` · showing first ${data.count}` : ""; @@ -1548,6 +1759,32 @@ async function filterByDescriptor(category, value) { setStatus(error.message, true); } } +function clearSearchFilters() { + $("search").value = ""; + $("family").value = ""; + $("kind").value = ""; + $("language").value = ""; + $("capability").value = ""; +} +function applyFilterPreset(preset) { + clearSearchFilters(); + if (preset === "logic") { + $("kind").value = "callable"; + $("capability").value = "logic"; + } else if (preset === "python") { + $("kind").value = "callable"; + $("language").value = "python"; + } else if (preset === "tests") { + $("kind").value = "test"; + } else if (preset === "routes") { + $("kind").value = "route"; + } else if (preset === "docs") { + $("kind").value = $("kind").querySelector('option[value="document"]') + ? "document" + : "manual"; + } + search(); +} async function loadNode(nodeId) { try { const showingFlow = state.mode === "flow"; @@ -1580,7 +1817,7 @@ async function loadNode(nodeId) { : showingLogic ? "control flow" : "neighborhood"; const suffix = data.truncated ? " · truncated at the safety limit" : ""; const status = showingLogic && !data.available - ? `No indexed Python logic is available for ${nodeId}` + ? `No indexed logic is available for ${nodeId}; use the Logic-ready filter` : `${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`; setStatus(status, showingLogic && !data.available); history.replaceState( @@ -1692,10 +1929,14 @@ function endDialogDrag(event) { state.dialogDrag = null; } $("search-form").addEventListener("submit", (event) => { event.preventDefault(); search(); }); -$("family").addEventListener("change", search); +for (const id of ["family", "kind", "language", "capability"]) { + $(id).addEventListener("change", search); +} +for (const button of document.querySelectorAll("[data-preset]")) { + button.addEventListener("click", () => applyFilterPreset(button.dataset.preset)); +} $("clear-result-filter").addEventListener("click", () => { - $("search").value = ""; - $("family").value = ""; + clearSearchFilters(); search(); }); $("view-nodes").addEventListener("click", () => setViewMode("nodes")); diff --git a/src/docforge/python_logic.py b/src/docforge/python_logic.py index 2cc7dee..ec20f06 100644 --- a/src/docforge/python_logic.py +++ b/src/docforge/python_logic.py @@ -271,11 +271,15 @@ class _FunctionLogicBuilder: if statement.orelse else condition.when_false ) - return self._merge("Branch merge", (*body_tails, *else_tails), statement) + return self._converge( + "Decision convergence", + (*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) + after_id = self._node("convergence", "Loop exit", 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: @@ -299,7 +303,7 @@ class _FunctionLogicBuilder: f"{prefix} {_expression(statement.target)} in {_expression(statement.iter)}", statement, ) - after_id = self._node("merge", "After loop", statement) + after_id = self._node("convergence", "Loop exit", statement) self._connect(incoming, loop_id) loop = _Loop(continue_id=loop_id, break_id=after_id) body_tails = self._statements( @@ -343,7 +347,11 @@ class _FunctionLogicBuilder: ) ) pending = () if _is_catch_all(case) else (_Tail(case_id, "when_false", "NEXT CASE"),) - return self._merge("Match merge", (*completed, *pending), statement) + return self._converge( + "Case convergence", + (*completed, *pending), + statement, + ) def _try( self, @@ -365,11 +373,15 @@ class _FunctionLogicBuilder: 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) + converged = self._converge( + "Exception convergence", + tuple(branches), + statement, + ) if not statement.finalbody: - return merged + return converged finally_id = self._node("finally", "finally", statement.finalbody[0]) - self._connect(merged, finally_id) + self._connect(converged, finally_id) return self._statements(statement.finalbody, (_Tail(finally_id),), loop=loop) def _condition( @@ -406,7 +418,7 @@ class _FunctionLogicBuilder: (_Tail(node_id, "when_false", "FALSE"),), ) - def _merge( + def _converge( self, label: str, incoming: tuple[_Tail, ...], @@ -414,9 +426,9 @@ class _FunctionLogicBuilder: ) -> tuple[_Tail, ...]: if not incoming: return () - merge_id = self._node("merge", label, source) - self._connect(incoming, merge_id) - return (_Tail(merge_id),) + convergence_id = self._node("convergence", label, source) + self._connect(incoming, convergence_id) + return (_Tail(convergence_id),) def _node(self, kind: str, label: str, source: ast.AST) -> str: if len(self.nodes) >= self.max_nodes: diff --git a/src/docforge/treesitter_logic.py b/src/docforge/treesitter_logic.py new file mode 100644 index 0000000..7f2d556 --- /dev/null +++ b/src/docforge/treesitter_logic.py @@ -0,0 +1,831 @@ +"""Tree-sitter-backed control-flow extraction for JavaScript and C++. + +Tree-sitter supplies concrete syntax trees. This module adds the small amount +of language-aware control-flow interpretation needed to emit DocForge's +language-neutral ``LogicProjection`` contract. Project code is parsed as +data; it is never imported, compiled, or executed. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Iterable +from dataclasses import dataclass +from functools import lru_cache + +import tree_sitter_cpp +import tree_sitter_javascript +from tree_sitter import Language, Node, Parser + +from .errors import DocForgeError +from .models import LogicEdge, LogicNode, LogicProjection + + +@dataclass(frozen=True) +class TreeSitterLogicOwner: + """One named function or method that should receive a Logic projection.""" + + owner_node_id: str + qualified_name: str + line: int + + +@dataclass(frozen=True) +class DiscoveredFunction: + """One parser-identified callable available to a project adapter.""" + + qualified_name: str + name: str + line: int + kind: str + + +@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 _Control: + break_id: str | None = None + continue_id: str | None = None + + +@dataclass(frozen=True) +class _LanguageProfile: + name: str + language: Language + root_type: str + block_types: frozenset[str] + function_types: frozenset[str] + loop_types: frozenset[str] + return_types: frozenset[str] + raise_types: frozenset[str] + switch_case_types: frozenset[str] + + +@lru_cache(maxsize=1) +def _javascript_profile() -> _LanguageProfile: + return _LanguageProfile( + name="javascript", + language=Language(tree_sitter_javascript.language()), + root_type="program", + block_types=frozenset({"program", "statement_block"}), + function_types=frozenset( + {"function_declaration", "generator_function_declaration", "method_definition"} + ), + loop_types=frozenset( + {"while_statement", "do_statement", "for_statement", "for_in_statement"} + ), + return_types=frozenset({"return_statement"}), + raise_types=frozenset({"throw_statement"}), + switch_case_types=frozenset({"switch_case", "switch_default"}), + ) + + +@lru_cache(maxsize=1) +def _cpp_profile() -> _LanguageProfile: + return _LanguageProfile( + name="cpp", + language=Language(tree_sitter_cpp.language()), + root_type="translation_unit", + block_types=frozenset({"translation_unit", "compound_statement"}), + function_types=frozenset({"function_definition"}), + loop_types=frozenset( + { + "while_statement", + "do_statement", + "for_statement", + "for_range_loop", + } + ), + return_types=frozenset({"return_statement", "co_return_statement"}), + raise_types=frozenset({"throw_statement"}), + switch_case_types=frozenset({"case_statement"}), + ) + + +def analyze_javascript_source( + source: str, + *, + source_id: str, + owners: Iterable[TreeSitterLogicOwner], + filename: str = "", + max_nodes_per_function: int = 2_000, +) -> tuple[LogicProjection, ...]: + """Build control-flow projections for named JavaScript functions and methods.""" + + return _analyze_tree_sitter_source( + source, + source_id=source_id, + owners=owners, + filename=filename, + profile=_javascript_profile(), + max_nodes_per_function=max_nodes_per_function, + ) + + +def discover_javascript_functions(source: str) -> tuple[DiscoveredFunction, ...]: + """Return named JavaScript functions, methods, and assigned arrow functions.""" + + return _discover_functions(source, _javascript_profile()) + + +def discover_cpp_functions(source: str) -> tuple[DiscoveredFunction, ...]: + """Return named C++ functions and methods.""" + + return _discover_functions(source, _cpp_profile()) + + +def analyze_cpp_source( + source: str, + *, + source_id: str, + owners: Iterable[TreeSitterLogicOwner], + filename: str = "", + max_nodes_per_function: int = 2_000, +) -> tuple[LogicProjection, ...]: + """Build control-flow projections for named C++ functions and methods.""" + + return _analyze_tree_sitter_source( + source, + source_id=source_id, + owners=owners, + filename=filename, + profile=_cpp_profile(), + max_nodes_per_function=max_nodes_per_function, + ) + + +def _discover_functions( + source: str, + profile: _LanguageProfile, +) -> tuple[DiscoveredFunction, ...]: + raw = source.encode("utf-8") + parser = Parser(profile.language) + tree = parser.parse(raw) + root = tree.root_node + if root.has_error: + return () + definitions = _function_definitions(root, raw, profile) + result: list[DiscoveredFunction] = [] + for (qualified_name, line), node in definitions.items(): + normalized = qualified_name.replace("::", ".") + name = normalized.split(".")[-1] + result.append( + DiscoveredFunction( + qualified_name=qualified_name, + name=name, + line=line, + kind=( + "method" + if node.type == "method_definition" or "." in normalized + else "function" + ), + ) + ) + return tuple( + sorted( + result, + key=lambda item: (item.qualified_name, item.line, item.kind), + ) + ) + + +def _analyze_tree_sitter_source( + source: str, + *, + source_id: str, + owners: Iterable[TreeSitterLogicOwner], + filename: str, + profile: _LanguageProfile, + max_nodes_per_function: int, +) -> tuple[LogicProjection, ...]: + if max_nodes_per_function < 2: + raise ValueError("max_nodes_per_function must allow entry and exit nodes") + raw = source.encode("utf-8") + parser = Parser(profile.language) + tree = parser.parse(raw) + if tree.root_node.type != profile.root_type or tree.root_node.has_error: + error = _first_error(tree.root_node) + raise DocForgeError( + "invalid_logic_source", + f"{profile.name.title()} source cannot be parsed for logic analysis", + source=filename, + line=(error.start_point.row + 1) if error is not None else 1, + ) + definitions = _function_definitions(tree.root_node, raw, profile) + 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 = _resolve_owner(owner, definitions) + if function is None: + raise DocForgeError( + "missing_logic_owner", + f"A requested {profile.name} 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( + _TreeSitterFunctionBuilder( + raw=raw, + source_id=source_id, + owner_node_id=owner.owner_node_id, + function=function, + profile=profile, + max_nodes=max_nodes_per_function, + ).build() + ) + return tuple(projections) + + +def _first_error(node: Node) -> Node | None: + if node.is_error or node.is_missing: + return node + for child in node.named_children: + error = _first_error(child) + if error is not None: + return error + return None + + +def _function_definitions( + root: Node, + raw: bytes, + profile: _LanguageProfile, +) -> dict[tuple[str, int], Node]: + definitions: dict[tuple[str, int], Node] = {} + + def visit(node: Node, scopes: tuple[str, ...]) -> None: + next_scopes = scopes + scope_name = _scope_name(node, raw, profile) + if scope_name: + next_scopes = (*scopes, scope_name) + function_name = _function_name(node, raw, profile) + if function_name: + qualified = ( + function_name if "::" in function_name else ".".join((*scopes, function_name)) + ) + function_node = node + if node.type == "variable_declarator": + function_node = node.child_by_field_name("value") or node + definitions[(qualified, node.start_point.row + 1)] = function_node + next_scopes = (*scopes, function_name) + for child in node.named_children: + visit(child, next_scopes) + + visit(root, ()) + return definitions + + +def _scope_name(node: Node, raw: bytes, profile: _LanguageProfile) -> str | None: + if profile.name == "javascript" and node.type in {"class_declaration", "class"}: + return _field_text(node, "name", raw) + if profile.name == "cpp" and node.type in { + "namespace_definition", + "class_specifier", + "struct_specifier", + "union_specifier", + }: + return _field_text(node, "name", raw) + return None + + +def _function_name(node: Node, raw: bytes, profile: _LanguageProfile) -> str | None: + if node.type in profile.function_types: + if profile.name == "javascript": + return _field_text(node, "name", raw) + declarator = node.child_by_field_name("declarator") + return _declarator_name(declarator, raw) if declarator is not None else None + if profile.name != "javascript" or node.type != "variable_declarator": + return None + value = node.child_by_field_name("value") + if value is None or value.type not in {"arrow_function", "function_expression"}: + return None + return _field_text(node, "name", raw) + + +def _declarator_name(node: Node, raw: bytes) -> str | None: + if node.type in { + "identifier", + "field_identifier", + "operator_name", + "destructor_name", + "qualified_identifier", + }: + return _text(node, raw) + for field in ("declarator", "name"): + child = node.child_by_field_name(field) + if child is not None: + result = _declarator_name(child, raw) + if result: + return result + for child in node.named_children: + result = _declarator_name(child, raw) + if result: + return result + return None + + +def _resolve_owner( + owner: TreeSitterLogicOwner, + definitions: dict[tuple[str, int], Node], +) -> Node | None: + exact = definitions.get((owner.qualified_name, owner.line)) + if exact is not None: + return exact + leaf = owner.qualified_name.replace("::", ".").split(".")[-1] + candidates = [ + node + for (qualified_name, line), node in definitions.items() + if line == owner.line and qualified_name.replace("::", ".").split(".")[-1] == leaf + ] + return candidates[0] if len(candidates) == 1 else None + + +class _TreeSitterFunctionBuilder: + def __init__( + self, + *, + raw: bytes, + source_id: str, + owner_node_id: str, + function: Node, + profile: _LanguageProfile, + max_nodes: int, + ) -> None: + self.raw = raw + self.source_id = source_id + self.owner_node_id = owner_node_id + self.function = function + self.profile = profile + 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] + name = _function_name(function, raw, profile) or owner_node_id.rsplit(".", 1)[-1] + self.entry_id = self._node("entry", f"Enter {name}", function) + self.exit_id = self._node("exit", f"Exit {name}", function) + + def build(self) -> LogicProjection: + body = self.function.child_by_field_name("body") + incoming = (_Tail(self.entry_id),) + if body is None: + tails = incoming + elif body.type in self.profile.block_types: + tails = self._statements(body.named_children, incoming, control=None) + else: + tails = self._expression_body(body, incoming) + 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 item: item.logic_id)), + edges=tuple( + sorted( + self.edges, + key=lambda item: ( + item.source_id, + item.ordinal, + item.relation, + item.target_id, + item.label or "", + ), + ) + ), + ) + + def _statements( + self, + statements: Iterable[Node], + incoming: tuple[_Tail, ...], + *, + control: _Control | None, + ) -> tuple[_Tail, ...]: + tails = incoming + for statement in statements: + if not tails: + break + tails = self._statement(statement, tails, control=control) + return tails + + def _statement( + self, + statement: Node, + incoming: tuple[_Tail, ...], + *, + control: _Control | None, + ) -> tuple[_Tail, ...]: + if statement.type in self.profile.block_types: + return self._statements(statement.named_children, incoming, control=control) + if statement.type == "if_statement": + return self._if(statement, incoming, control=control) + if statement.type in self.profile.loop_types: + return self._loop(statement, incoming) + if statement.type == "switch_statement": + return self._switch(statement, incoming, control=control) + if statement.type == "try_statement": + return self._try(statement, incoming, control=control) + if statement.type in self.profile.return_types: + value = next(iter(statement.named_children), None) + label = "return" if value is None else f"return {_compact(_text(value, self.raw))}" + node_id = self._node("return", label, statement) + self._connect(incoming, node_id) + self._edge(node_id, "return", self.exit_id, "RETURN") + return () + if statement.type in self.profile.raise_types: + value = next(iter(statement.named_children), None) + keyword = "throw" if self.profile.name in {"javascript", "cpp"} else "raise" + label = keyword if value is None else f"{keyword} {_compact(_text(value, self.raw))}" + node_id = self._node("raise", label, statement) + self._connect(incoming, node_id) + self._edge(node_id, "raise", self.exit_id, keyword.upper()) + return () + if statement.type == "break_statement": + node_id = self._node("break", "break", statement) + self._connect(incoming, node_id) + target = control.break_id if control is not None else None + self._edge(node_id, "break" if target else "next", target or self.exit_id, "BREAK") + return () + if statement.type == "continue_statement": + node_id = self._node("continue", "continue", statement) + self._connect(incoming, node_id) + target = control.continue_id if control is not None else None + self._edge( + node_id, + "continue" if target else "next", + target or self.exit_id, + "CONTINUE", + ) + return () + if statement.type in {"function_declaration", "function_definition", "method_definition"}: + return incoming + if statement.type in {"else_clause", "finally_clause", "catch_clause"}: + body = statement.child_by_field_name("body") + return ( + self._statement(body, incoming, control=control) if body is not None else incoming + ) + node_id = self._node( + "call" if _contains_type(statement, "call_expression") else "action", + _compact(_text(statement, self.raw)), + statement, + ) + self._connect(incoming, node_id) + return (_Tail(node_id),) + + def _if( + self, + statement: Node, + incoming: tuple[_Tail, ...], + *, + control: _Control | None, + ) -> tuple[_Tail, ...]: + expression = statement.child_by_field_name("condition") + if expression is None: + expression = _first_named(statement) + condition = self._condition(_unwrap_condition(expression), incoming) + consequence = statement.child_by_field_name("consequence") + alternative = statement.child_by_field_name("alternative") + body_tails = ( + self._statement(consequence, condition.when_true, control=control) + if consequence is not None + else condition.when_true + ) + else_tails = ( + self._statement(alternative, condition.when_false, control=control) + if alternative is not None + else condition.when_false + ) + return self._converge("Decision convergence", (*body_tails, *else_tails), statement) + + def _loop(self, statement: Node, incoming: tuple[_Tail, ...]) -> tuple[_Tail, ...]: + after_id = self._node("convergence", "Loop exit", statement) + condition_node = statement.child_by_field_name("condition") + body = statement.child_by_field_name("body") + if statement.type in {"for_in_statement", "for_range_loop"}: + loop_id = self._node( + "loop", _compact(_header_text(statement, body, self.raw)), statement + ) + self._connect(incoming, loop_id) + condition = _Condition( + loop_id, + (_Tail(loop_id, "when_true", "ITEM"),), + (_Tail(loop_id, "when_false", "EXHAUSTED"),), + ) + elif condition_node is not None: + condition = self._condition(_unwrap_condition(condition_node), incoming) + loop_id = condition.entry_id + else: + loop_id = self._node( + "loop", _compact(_header_text(statement, body, self.raw)), statement + ) + self._connect(incoming, loop_id) + condition = _Condition( + loop_id, + (_Tail(loop_id, "when_true", "ITERATE"),), + (_Tail(loop_id, "when_false", "EXIT"),), + ) + control = _Control(break_id=after_id, continue_id=loop_id) + body_tails = ( + self._statement(body, condition.when_true, control=control) + if body is not None + else condition.when_true + ) + for tail in body_tails: + self._edge(tail.source_id, "loop", loop_id, "NEXT ITERATION") + self._connect(condition.when_false, after_id) + return (_Tail(after_id),) if self._has_incoming(after_id) else () + + def _switch( + self, + statement: Node, + incoming: tuple[_Tail, ...], + *, + control: _Control | None, + ) -> tuple[_Tail, ...]: + expression = ( + statement.child_by_field_name("value") + or statement.child_by_field_name("condition") + or _first_named(statement) + ) + switch_id = self._node( + "condition", + f"switch {_compact(_text(_unwrap_condition(expression), self.raw))}", + statement, + ) + self._connect(incoming, switch_id) + body = statement.child_by_field_name("body") + cases = [ + child + for child in (body.named_children if body is not None else ()) + if child.type in self.profile.switch_case_types + ] + convergence_id = self._node("convergence", "Case convergence", statement) + switch_control = _Control( + break_id=convergence_id, + continue_id=control.continue_id if control is not None else None, + ) + completed: list[_Tail] = [] + for case in cases: + case_value = case.child_by_field_name("value") + label = ( + "default" if case_value is None else f"case {_compact(_text(case_value, self.raw))}" + ) + case_id = self._node("case", label, case) + self._edge(switch_id, "case", case_id, label.upper()) + body_nodes = tuple( + child + for child in case.named_children + if case_value is None or child.id != case_value.id + ) + completed.extend( + self._statements(body_nodes, (_Tail(case_id),), control=switch_control) + ) + self._connect(tuple(completed), convergence_id) + return (_Tail(convergence_id),) if self._has_incoming(convergence_id) else () + + def _try( + self, + statement: Node, + incoming: tuple[_Tail, ...], + *, + control: _Control | None, + ) -> tuple[_Tail, ...]: + try_id = self._node("try", "try", statement) + self._connect(incoming, try_id) + body = statement.child_by_field_name("body") + normal = ( + self._statement(body, (_Tail(try_id),), control=control) + if body is not None + else (_Tail(try_id),) + ) + branches: list[_Tail] = list(normal) + handlers = [child for child in statement.named_children if child.type == "catch_clause"] + handler = statement.child_by_field_name("handler") + if handler is not None and handler not in handlers: + handlers.append(handler) + for catch in handlers: + parameter = catch.child_by_field_name("parameter") or catch.child_by_field_name( + "parameters" + ) + label = ( + "catch" if parameter is None else f"catch {_compact(_text(parameter, self.raw))}" + ) + catch_id = self._node("except", label, catch) + self._edge(try_id, "exception", catch_id, label.upper()) + catch_body = catch.child_by_field_name("body") + branches.extend( + self._statement(catch_body, (_Tail(catch_id),), control=control) + if catch_body is not None + else (_Tail(catch_id),) + ) + converged = self._converge("Exception convergence", tuple(branches), statement) + finalizer = statement.child_by_field_name("finalizer") + if finalizer is None: + finalizer = next( + (child for child in statement.named_children if child.type == "finally_clause"), + None, + ) + if finalizer is None: + return converged + final_id = self._node("finally", "finally", finalizer) + self._connect(converged, final_id) + final_body = finalizer.child_by_field_name("body") + return ( + self._statement(final_body, (_Tail(final_id),), control=control) + if final_body is not None + else (_Tail(final_id),) + ) + + def _condition( + self, + expression: Node, + incoming: tuple[_Tail, ...], + ) -> _Condition: + expression = _unwrap_condition(expression) + text = _text(expression, self.raw).strip() + if expression.type == "unary_expression" and text.startswith("!"): + operand = next(iter(expression.named_children), None) + if operand is not None: + inner = self._condition(operand, incoming) + return _Condition(inner.entry_id, inner.when_false, inner.when_true) + if expression.type == "binary_expression": + left = expression.child_by_field_name("left") + right = expression.child_by_field_name("right") + operator = _operator_between(left, right, self.raw) + if left is not None and right is not None and operator in {"&&", "||"}: + first = self._condition(left, incoming) + if operator == "&&": + second = self._condition(right, first.when_true) + return _Condition( + first.entry_id, + second.when_true, + (*first.when_false, *second.when_false), + ) + second = self._condition(right, first.when_false) + return _Condition( + first.entry_id, + (*first.when_true, *second.when_true), + second.when_false, + ) + node_id = self._node("condition", _compact(text), expression) + self._connect(incoming, node_id) + return _Condition( + node_id, + (_Tail(node_id, "when_true", "TRUE"),), + (_Tail(node_id, "when_false", "FALSE"),), + ) + + def _expression_body( + self, + expression: Node, + incoming: tuple[_Tail, ...], + ) -> tuple[_Tail, ...]: + if expression.type == "ternary_expression": + condition_node = expression.child_by_field_name("condition") + consequence = expression.child_by_field_name("consequence") + alternative = expression.child_by_field_name("alternative") + if condition_node is not None and consequence is not None and alternative is not None: + condition = self._condition(condition_node, incoming) + true_id = self._node( + "return", + f"return {_compact(_text(consequence, self.raw))}", + consequence, + ) + false_id = self._node( + "return", + f"return {_compact(_text(alternative, self.raw))}", + alternative, + ) + self._connect(condition.when_true, true_id) + self._connect(condition.when_false, false_id) + self._edge(true_id, "return", self.exit_id, "RETURN") + self._edge(false_id, "return", self.exit_id, "RETURN") + return () + node_id = self._node( + "return", + f"return {_compact(_text(expression, self.raw))}", + expression, + ) + self._connect(incoming, node_id) + self._edge(node_id, "return", self.exit_id, "RETURN") + return () + + def _converge( + self, + label: str, + incoming: tuple[_Tail, ...], + source: Node, + ) -> tuple[_Tail, ...]: + if not incoming: + return () + convergence_id = self._node("convergence", label, source) + self._connect(incoming, convergence_id) + return (_Tail(convergence_id),) + + def _node(self, kind: str, label: str, source: Node) -> 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, + ) + self._sequence += 1 + line = source.start_point.row + 1 + column = source.start_point.column + 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 _field_text(node: Node, field: str, raw: bytes) -> str | None: + child = node.child_by_field_name(field) + return _text(child, raw) if child is not None else None + + +def _first_named(node: Node) -> Node: + return node.named_children[0] if node.named_children else node + + +def _unwrap_condition(node: Node) -> Node: + current = node + while current.type in {"parenthesized_expression", "condition_clause"}: + value = current.child_by_field_name("value") + current = value or _first_named(current) + return current + + +def _operator_between(left: Node | None, right: Node | None, raw: bytes) -> str: + if left is None or right is None: + return "" + return raw[left.end_byte : right.start_byte].decode("utf-8", errors="replace").strip() + + +def _header_text(statement: Node, body: Node | None, raw: bytes) -> str: + end = body.start_byte if body is not None else statement.end_byte + return raw[statement.start_byte : end].decode("utf-8", errors="replace").strip() + + +def _contains_type(node: Node, node_type: str) -> bool: + if node.type == node_type: + return True + return any(_contains_type(child, node_type) for child in node.named_children) + + +def _text(node: Node, raw: bytes) -> str: + return raw[node.start_byte : node.end_byte].decode("utf-8", errors="replace") + + +def _compact(value: str, limit: int = 240) -> str: + compact = " ".join(value.strip().split()) + return compact if len(compact) <= limit else f"{compact[: limit - 1]}…" diff --git a/src/docforge/viewer_manager.py b/src/docforge/viewer_manager.py index d7ddbfa..7bad944 100644 --- a/src/docforge/viewer_manager.py +++ b/src/docforge/viewer_manager.py @@ -611,7 +611,14 @@ class ViewerManagerClient: if node_id is not None: snapshot.require_node(node_id) elif query is not None: - snapshot.search(query=query, family=None, limit=1) + snapshot.search( + query=query, + family=None, + kind=None, + language=None, + capability=None, + limit=1, + ) response = self._request( { "action": "start", diff --git a/src/docforge/visualization.py b/src/docforge/visualization.py index e46eee5..96bab92 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@16" +VISUALIZATION_TEMPLATE = "graph-browser@17" DEFAULT_EDGE_LIMIT = 100 MAX_EDGE_LIMIT = 400 MAX_LINEAGE_EDGE_LIMIT = 1_000 @@ -166,6 +166,8 @@ class VisualizationIndexSnapshot: authorities=_facet_rows(connection, "nodes", "authority"), statuses=_facet_rows(connection, "nodes", "status"), relations=_facet_rows(connection, "edges", "relation"), + tags=_tag_facet_rows(connection), + capabilities=_capability_facet_rows(connection), max_results=self.max_results, max_depth=self.max_depth, snapshot=True, @@ -176,9 +178,18 @@ class VisualizationIndexSnapshot: *, query: str, family: str | None, + kind: str | None, + language: str | None, + capability: str | None, limit: int, ) -> dict[str, object]: bounded = self._bounded_limit(limit) + clauses, filter_values = _node_filter_clauses( + family=family, + kind=kind, + language=language, + capability=capability, + ) with self._connection() as connection: if query: if len(query) > self.max_query_chars: @@ -191,10 +202,8 @@ class VisualizationIndexSnapshot: expression = " AND ".join( f'"{term.replace(chr(34), chr(34) * 2)}"' for term in terms ) - family_clause = "AND nodes.family = ?" if family else "" - values: tuple[object, ...] = ( - (expression, family, bounded) if family else (expression, bounded) - ) + filter_clause = "".join(f" AND {clause}" for clause in clauses) + values = (expression, *filter_values, bounded) rows = connection.execute( """ SELECT nodes.*, bm25(node_fts) AS rank, @@ -202,7 +211,7 @@ class VisualizationIndexSnapshot: FROM node_fts JOIN nodes USING(node_id) WHERE node_fts MATCH ? """ - + family_clause + + filter_clause + " ORDER BY rank, nodes.node_id LIMIT ?", values, ).fetchall() @@ -212,16 +221,19 @@ class VisualizationIndexSnapshot: item.update({"rank": row["rank"], "snippet": row["snippet"]}) results.append(item) else: - family_clause = "WHERE family = ?" if family else "" - values = (family, bounded) if family else (bounded,) + filter_clause = f"WHERE {' AND '.join(clauses)}" if clauses else "" + values = (*filter_values, bounded) rows = connection.execute( - f"SELECT * FROM nodes {family_clause} ORDER BY node_id LIMIT ?", + f"SELECT nodes.* FROM nodes {filter_clause} ORDER BY node_id LIMIT ?", values, ).fetchall() results = [_node_dict(row, include_content=False) for row in rows] return self._result( query=query, family=family, + kind=kind, + language=language, + capability=capability, count=len(results), results=results, snapshot=True, @@ -865,7 +877,14 @@ class VisualizationRunner: if node_id is not None: reader.require_node(node_id) elif query is not None: - reader.search(query=query, family=None, limit=1) + reader.search( + query=query, + family=None, + kind=None, + language=None, + capability=None, + limit=1, + ) with self._lock: self._reader = reader @@ -1135,8 +1154,18 @@ class VisualizationRunner: ) -> dict[str, object]: query = _one(params, "q").strip() family = _one(params, "family").strip() or None + kind = _one(params, "kind").strip() or None + language = _one(params, "language").strip() or None + capability = _one(params, "capability").strip() or None limit = _integer(_one(params, "limit") or "50") - return reader.search(query=query, family=family, limit=limit) + return reader.search( + query=query, + family=family, + kind=kind, + language=language, + capability=capability, + limit=limit, + ) def _node( self, @@ -1523,7 +1552,14 @@ class PersistentVisualizationRunner: if node_id is not None: snapshot.require_node(node_id) elif query is not None: - snapshot.search(query=query, family=None, limit=1) + snapshot.search( + query=query, + family=None, + kind=None, + language=None, + capability=None, + limit=1, + ) with self._locked_registry(): existing = self._read_registry() @@ -1740,6 +1776,64 @@ def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list return [{"value": row[0], "count": row[1]} for row in rows] +def _tag_facet_rows(connection: sqlite3.Connection) -> list[dict[str, object]]: + rows = connection.execute( + """ + SELECT value, COUNT(*) AS count + FROM nodes, json_each(nodes.tags_json) + GROUP BY value + ORDER BY count DESC, value + """ + ).fetchall() + return [{"value": row["value"], "count": row["count"]} for row in rows] + + +def _capability_facet_rows(connection: sqlite3.Connection) -> list[dict[str, object]]: + logic_count = connection.execute("SELECT COUNT(*) FROM logic_owners").fetchone()[0] + source_count = connection.execute( + "SELECT COUNT(*) FROM nodes WHERE source_path <> ''" + ).fetchone()[0] + return [ + {"value": "logic", "count": logic_count}, + {"value": "source", "count": source_count}, + ] + + +def _node_filter_clauses( + *, + family: str | None, + kind: str | None, + language: str | None, + capability: str | None, +) -> tuple[list[str], list[object]]: + clauses: list[str] = [] + values: list[object] = [] + for label, value in (("family", family), ("kind", kind), ("language", language)): + if value is not None and len(value) > 160: + raise DocForgeError("invalid_filter", f"Node {label} filter is invalid") + if family: + clauses.append("nodes.family = ?") + values.append(family) + if kind == "callable": + clauses.append( + "EXISTS (SELECT 1 FROM json_each(nodes.tags_json) " + "WHERE value IN ('function', 'method', 'nested-function'))" + ) + elif kind: + clauses.append("EXISTS (SELECT 1 FROM json_each(nodes.tags_json) WHERE value = ?)") + values.append(kind) + if language: + clauses.append("EXISTS (SELECT 1 FROM json_each(nodes.tags_json) WHERE value = ?)") + values.append(language) + if capability == "logic": + clauses.append("EXISTS (SELECT 1 FROM logic_owners WHERE owner_node_id = nodes.node_id)") + elif capability == "source": + clauses.append("nodes.source_path <> ''") + elif capability is not None: + raise DocForgeError("invalid_filter", "Node capability filter is unsupported") + return clauses, values + + def _read_browser_asset(name: str) -> str: return resources.files("docforge.assets").joinpath(name).read_text(encoding="utf-8") diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index bca685a..a9e57e2 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -133,7 +133,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase): visualization = results[12].structuredContent["visualization"] self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["project_bound"]) - self.assertEqual("graph-browser@16", visualization["template"]) + self.assertEqual("graph-browser@17", 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_treesitter_logic.py b/tests/test_treesitter_logic.py new file mode 100644 index 0000000..6a7014f --- /dev/null +++ b/tests/test_treesitter_logic.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import unittest + +from docforge.errors import DocForgeError +from docforge.treesitter_logic import ( + TreeSitterLogicOwner, + analyze_cpp_source, + analyze_javascript_source, +) + + +class JavaScriptLogicTests(unittest.TestCase): + def test_branches_short_circuit_and_converge(self) -> None: + source = """ +function choose(enabled, ready) { + if (enabled && ready()) { + accept(); + } else { + reject(); + } + return enabled; +} +""".strip() + projection = analyze_javascript_source( + source, + source_id="source.javascript", + owners=(TreeSitterLogicOwner("js.symbol.choose", "choose", 1),), + )[0] + + kinds = {node.kind for node in projection.nodes} + labels = {node.label for node in projection.nodes} + relations = {edge.relation for edge in projection.edges} + self.assertIn("condition", kinds) + self.assertIn("convergence", kinds) + self.assertIn("Decision convergence", labels) + self.assertIn("when_true", relations) + self.assertIn("when_false", relations) + self.assertIn("return", relations) + + def test_arrow_function_expression_is_a_returning_projection(self) -> None: + source = "const choose = (enabled) => enabled ? accept() : reject();" + projection = analyze_javascript_source( + source, + source_id="source.javascript", + owners=(TreeSitterLogicOwner("js.symbol.choose", "choose", 1),), + )[0] + + labels = {node.label for node in projection.nodes} + self.assertIn("enabled", labels) + self.assertIn("return accept()", labels) + self.assertIn("return reject()", labels) + + def test_loops_switch_and_exception_paths_are_preserved(self) -> None: + source = """ +function process(items, mode) { + for (const item of items) { + if (!item.ready) continue; + use(item); + } + switch (mode) { + case 1: + one(); + break; + default: + fallback(); + } + try { + risk(); + } catch (error) { + recover(error); + } finally { + clean(); + } +} +""".strip() + projection = analyze_javascript_source( + source, + source_id="source.javascript", + owners=(TreeSitterLogicOwner("js.symbol.process", "process", 1),), + )[0] + + kinds = {node.kind for node in projection.nodes} + labels = {node.label for node in projection.nodes} + relations = {edge.relation for edge in projection.edges} + self.assertTrue({"loop", "continue", "case", "try", "except", "finally"} <= kinds) + self.assertIn("Case convergence", labels) + self.assertIn("Exception convergence", labels) + self.assertTrue({"loop", "continue", "case", "exception"} <= relations) + + +class CppLogicTests(unittest.TestCase): + def test_cpp_function_branches_and_throws(self) -> None: + source = """ +int choose(bool enabled) { + if (enabled) { + return 1; + } + throw Error(); +} +""".strip() + projection = analyze_cpp_source( + source, + source_id="source.cpp", + owners=(TreeSitterLogicOwner("cpp.symbol.choose", "choose", 1),), + )[0] + + kinds = {node.kind for node in projection.nodes} + relations = {edge.relation for edge in projection.edges} + self.assertTrue({"entry", "condition", "return", "raise", "exit"} <= kinds) + self.assertTrue({"when_true", "when_false", "return", "raise"} <= relations) + + def test_cpp_qualified_method_owner_is_resolved(self) -> None: + source = """ +class Worker { +public: + int run(bool ready) { + while (ready) { + ready = tick(); + } + return 0; + } +}; +""".strip() + projection = analyze_cpp_source( + source, + source_id="source.cpp", + owners=(TreeSitterLogicOwner("cpp.symbol.worker.run", "Worker.run", 3),), + )[0] + + labels = {node.label for node in projection.nodes} + self.assertIn("Loop exit", labels) + self.assertIn("return 0", labels) + + def test_invalid_source_and_missing_owner_fail_closed(self) -> None: + with self.assertRaises(DocForgeError) as invalid: + analyze_cpp_source( + "int broken( {", + source_id="source.cpp", + owners=(), + ) + self.assertEqual(invalid.exception.code, "invalid_logic_source") + + with self.assertRaises(DocForgeError) as missing: + analyze_javascript_source( + "function exists() {}", + source_id="source.javascript", + owners=(TreeSitterLogicOwner("js.symbol.missing", "missing", 1),), + ) + self.assertEqual(missing.exception.code, "missing_logic_owner") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 7dc8cd2..0080042 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -72,6 +72,14 @@ class VisualizationTests(unittest.TestCase): first = snapshot.node("guide.workflow", depth=2, limit=2) second = snapshot.node("guide.workflow", depth=2, limit=2) filtered = snapshot.filter_nodes(category="tag", value="canonical", limit=2) + searched = snapshot.search( + query="", + family="guide", + kind="canonical", + language=None, + capability="source", + limit=2, + ) source = snapshot.source("guide.workflow") flow = snapshot.lineage("guide.workflow", limit=20) web = snapshot.web("guide.workflow", depth=2, limit=20) @@ -91,6 +99,11 @@ class VisualizationTests(unittest.TestCase): self.assertEqual(1, filtered["total"]) self.assertFalse(filtered["truncated"]) self.assertEqual("guide.foundation", filtered["results"][0]["node_id"]) + self.assertEqual(1, searched["count"]) + self.assertEqual("guide.foundation", searched["results"][0]["node_id"]) + self.assertIn({"value": "canonical", "count": 1}, overview["tags"]) + self.assertIn({"value": "source", "count": 3}, overview["capabilities"]) + self.assertIn({"value": "logic", "count": 0}, overview["capabilities"]) self.assertLessEqual(len(first["edges"]), 2) self.assertEqual("docs/content/workflow.md", source["source_path"]) self.assertIn("Editors change canonical nodes", source["content"]) @@ -238,10 +251,17 @@ The test verifies the default behavior. 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="kind"', _GRAPH_BROWSER_HTML) + self.assertIn('id="language"', _GRAPH_BROWSER_HTML) + self.assertIn('id="capability"', _GRAPH_BROWSER_HTML) + self.assertIn('data-preset="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) self.assertIn("state.hiddenNodes.add(nodeId)", _GRAPH_BROWSER_JAVASCRIPT) + self.assertIn("applyTraceHighlight", _GRAPH_BROWSER_JAVASCRIPT) + self.assertIn("layoutLogic", _GRAPH_BROWSER_JAVASCRIPT) + self.assertIn("trace-connected", _GRAPH_BROWSER_CSS) self.assertIn( "grid-template-rows: auto minmax(0, 1fr) auto", _GRAPH_BROWSER_CSS, diff --git a/uv.lock b/uv.lock index d0366a7..ced1c11 100644 --- a/uv.lock +++ b/uv.lock @@ -211,6 +211,9 @@ source = { editable = "." } dependencies = [ { name = "markdown-it-py" }, { name = "mcp" }, + { name = "tree-sitter" }, + { name = "tree-sitter-cpp" }, + { name = "tree-sitter-javascript" }, ] [package.dev-dependencies] @@ -223,6 +226,9 @@ dev = [ requires-dist = [ { name = "markdown-it-py", specifier = ">=4.2,<5" }, { name = "mcp", specifier = ">=1.28,<2" }, + { name = "tree-sitter", specifier = ">=0.25,<0.26" }, + { name = "tree-sitter-cpp", specifier = ">=0.23,<0.24" }, + { name = "tree-sitter-javascript", specifier = ">=0.25,<0.26" }, ] [package.metadata.requires-dev] @@ -736,6 +742,66 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, ] +[[package]] +name = "tree-sitter" +version = "0.25.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/7c/0350cfc47faadc0d3cf7d8237a4e34032b3014ddf4a12ded9933e1648b55/tree-sitter-0.25.2.tar.gz", hash = "sha256:fe43c158555da46723b28b52e058ad444195afd1db3ca7720c59a254544e9c20", size = 177961, upload-time = "2025-09-25T17:37:59.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/9e/20c2a00a862f1c2897a436b17edb774e831b22218083b459d0d081c9db33/tree_sitter-0.25.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ddabfff809ffc983fc9963455ba1cecc90295803e06e140a4c83e94c1fa3d960", size = 146941, upload-time = "2025-09-25T17:37:34.813Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/8512e2062e652a1016e840ce36ba1cc33258b0dcc4e500d8089b4054afec/tree_sitter-0.25.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c0c0ab5f94938a23fe81928a21cc0fac44143133ccc4eb7eeb1b92f84748331c", size = 137699, upload-time = "2025-09-25T17:37:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/47/8a/d48c0414db19307b0fb3bb10d76a3a0cbe275bb293f145ee7fba2abd668e/tree_sitter-0.25.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd12d80d91d4114ca097626eb82714618dcdfacd6a5e0955216c6485c350ef99", size = 607125, upload-time = "2025-09-25T17:37:37.725Z" }, + { url = "https://files.pythonhosted.org/packages/39/d1/b95f545e9fc5001b8a78636ef942a4e4e536580caa6a99e73dd0a02e87aa/tree_sitter-0.25.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b43a9e4c89d4d0839de27cd4d6902d33396de700e9ff4c5ab7631f277a85ead9", size = 635418, upload-time = "2025-09-25T17:37:38.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/4d/b734bde3fb6f3513a010fa91f1f2875442cdc0382d6a949005cd84563d8f/tree_sitter-0.25.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbb1706407c0e451c4f8cc016fec27d72d4b211fdd3173320b1ada7a6c74c3ac", size = 631250, upload-time = "2025-09-25T17:37:40.039Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/5f654994f36d10c64d50a192239599fcae46677491c8dd53e7579c35a3e3/tree_sitter-0.25.2-cp312-cp312-win_amd64.whl", hash = "sha256:6d0302550bbe4620a5dc7649517c4409d74ef18558276ce758419cf09e578897", size = 127156, upload-time = "2025-09-25T17:37:41.132Z" }, + { url = "https://files.pythonhosted.org/packages/67/23/148c468d410efcf0a9535272d81c258d840c27b34781d625f1f627e2e27d/tree_sitter-0.25.2-cp312-cp312-win_arm64.whl", hash = "sha256:0c8b6682cac77e37cfe5cf7ec388844957f48b7bd8d6321d0ca2d852994e10d5", size = 113984, upload-time = "2025-09-25T17:37:42.074Z" }, + { url = "https://files.pythonhosted.org/packages/8c/67/67492014ce32729b63d7ef318a19f9cfedd855d677de5773476caf771e96/tree_sitter-0.25.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0628671f0de69bb279558ef6b640bcfc97864fe0026d840f872728a86cd6b6cd", size = 146926, upload-time = "2025-09-25T17:37:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/a278b15e6b263e86c5e301c82a60923fa7c59d44f78d7a110a89a413e640/tree_sitter-0.25.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f5ddcd3e291a749b62521f71fc953f66f5fd9743973fd6dd962b092773569601", size = 137712, upload-time = "2025-09-25T17:37:44.039Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/423bba15d2bf6473ba67846ba5244b988cd97a4b1ea2b146822162256794/tree_sitter-0.25.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd88fbb0f6c3a0f28f0a68d72df88e9755cf5215bae146f5a1bdc8362b772053", size = 607873, upload-time = "2025-09-25T17:37:45.477Z" }, + { url = "https://files.pythonhosted.org/packages/ed/4c/b430d2cb43f8badfb3a3fa9d6cd7c8247698187b5674008c9d67b2a90c8e/tree_sitter-0.25.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b878e296e63661c8e124177cc3084b041ba3f5936b43076d57c487822426f614", size = 636313, upload-time = "2025-09-25T17:37:46.68Z" }, + { url = "https://files.pythonhosted.org/packages/9d/27/5f97098dbba807331d666a0997662e82d066e84b17d92efab575d283822f/tree_sitter-0.25.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d77605e0d353ba3fe5627e5490f0fbfe44141bafa4478d88ef7954a61a848dae", size = 631370, upload-time = "2025-09-25T17:37:47.993Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3c/87caaed663fabc35e18dc704cd0e9800a0ee2f22bd18b9cbe7c10799895d/tree_sitter-0.25.2-cp313-cp313-win_amd64.whl", hash = "sha256:463c032bd02052d934daa5f45d183e0521ceb783c2548501cf034b0beba92c9b", size = 127157, upload-time = "2025-09-25T17:37:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/d5/23/f8467b408b7988aff4ea40946a4bd1a2c1a73d17156a9d039bbaff1e2ceb/tree_sitter-0.25.2-cp313-cp313-win_arm64.whl", hash = "sha256:b3f63a1796886249bd22c559a5944d64d05d43f2be72961624278eff0dcc5cb8", size = 113975, upload-time = "2025-09-25T17:37:49.922Z" }, + { url = "https://files.pythonhosted.org/packages/07/e3/d9526ba71dfbbe4eba5e51d89432b4b333a49a1e70712aa5590cd22fc74f/tree_sitter-0.25.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:65d3c931013ea798b502782acab986bbf47ba2c452610ab0776cf4a8ef150fc0", size = 146776, upload-time = "2025-09-25T17:37:50.898Z" }, + { url = "https://files.pythonhosted.org/packages/42/97/4bd4ad97f85a23011dd8a535534bb1035c4e0bac1234d58f438e15cff51f/tree_sitter-0.25.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bda059af9d621918efb813b22fb06b3fe00c3e94079c6143fcb2c565eb44cb87", size = 137732, upload-time = "2025-09-25T17:37:51.877Z" }, + { url = "https://files.pythonhosted.org/packages/b6/19/1e968aa0b1b567988ed522f836498a6a9529a74aab15f09dd9ac1e41f505/tree_sitter-0.25.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eac4e8e4c7060c75f395feec46421eb61212cb73998dbe004b7384724f3682ab", size = 609456, upload-time = "2025-09-25T17:37:52.925Z" }, + { url = "https://files.pythonhosted.org/packages/48/b6/cf08f4f20f4c9094006ef8828555484e842fc468827ad6e56011ab668dbd/tree_sitter-0.25.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:260586381b23be33b6191a07cea3d44ecbd6c01aa4c6b027a0439145fcbc3358", size = 636772, upload-time = "2025-09-25T17:37:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/57/e2/d42d55bf56360987c32bc7b16adb06744e425670b823fb8a5786a1cea991/tree_sitter-0.25.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d2ee1acbacebe50ba0f85fff1bc05e65d877958f00880f49f9b2af38dce1af0", size = 631522, upload-time = "2025-09-25T17:37:55.833Z" }, + { url = "https://files.pythonhosted.org/packages/03/87/af9604ebe275a9345d88c3ace0cf2a1341aa3f8ef49dd9fc11662132df8a/tree_sitter-0.25.2-cp314-cp314-win_amd64.whl", hash = "sha256:4973b718fcadfb04e59e746abfbb0288694159c6aeecd2add59320c03368c721", size = 130864, upload-time = "2025-09-25T17:37:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6e/e64621037357acb83d912276ffd30a859ef117f9c680f2e3cb955f47c680/tree_sitter-0.25.2-cp314-cp314-win_arm64.whl", hash = "sha256:b8d4429954a3beb3e844e2872610d2a4800ba4eb42bb1990c6a4b1949b18459f", size = 117470, upload-time = "2025-09-25T17:37:58.431Z" }, +] + +[[package]] +name = "tree-sitter-cpp" +version = "0.23.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/2c/4dd63d705a8933543cad9b92ff31be849b164fec91a6eb63475ebc9ce668/tree_sitter_cpp-0.23.4.tar.gz", hash = "sha256:6a59c4cebb1ad1dc2e8d586cf8a72b39d21b8108b7b139d089719e81a339e41d", size = 940358, upload-time = "2024-11-11T06:59:24.934Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b6/ac/11d56670f7b048362db872ca866fd00ba2002a322ab179f047b7c0fb2910/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:aacb1759f0efd9dbc25bd8ee88184a340483018869f75412d9c3bc32c039a520", size = 287861, upload-time = "2024-11-11T06:59:15.005Z" }, + { url = "https://files.pythonhosted.org/packages/12/1c/0337c016bdc00a77a3326d12f10ee836401dd28f27db6fd5b7734bfb21ed/tree_sitter_cpp-0.23.4-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bc3c404d9f0cbd87951213a85440afbf4c31e718f8d907fa9ee12bea4b8d276f", size = 315513, upload-time = "2024-11-11T06:59:16.679Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7b/dd38c049b10ed7fda118b903a1d28a8b55a36b98c30606ef90e8f374c6de/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ccc43ddf1279d5d5a4ef190373f4cb16522801bec4492bcd4754edf2aeba2b7b", size = 334813, upload-time = "2024-11-11T06:59:18.253Z" }, + { url = "https://files.pythonhosted.org/packages/6a/4d/23e390234d2acd351f5563b1079c515d7c1fe13ddb7392cee543be74dda3/tree_sitter_cpp-0.23.4-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:773d2cafc08bbc0f998687fa33f42f378c1a371cdb582870c4d13abb06092706", size = 316110, upload-time = "2024-11-11T06:59:19.823Z" }, + { url = "https://files.pythonhosted.org/packages/32/c7/b94a7e0e803af9d3bd4608fb4f0cfb2e9e233abaf0a38c928bfb0b1a025d/tree_sitter_cpp-0.23.4-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:247d127f0eb6574b0f6b30c0151e0bd0774e2e7acf9c558bdf9fbb8adc2e80c0", size = 308242, upload-time = "2024-11-11T06:59:21.466Z" }, + { url = "https://files.pythonhosted.org/packages/37/7e/909e52b3dec09c475140b0e175511e275d0d00ba2dbd7c68102d377ae0f6/tree_sitter_cpp-0.23.4-cp39-abi3-win_amd64.whl", hash = "sha256:68606a45bea92669d155399e1239f771a7767d8683cd8f8e30e7d813107030ca", size = 290997, upload-time = "2024-11-11T06:59:22.432Z" }, + { url = "https://files.pythonhosted.org/packages/d4/6a/65435d4d1f4c735be7ffe52d7c2e7b8a7f7c2790343a2719c60c548611c8/tree_sitter_cpp-0.23.4-cp39-abi3-win_arm64.whl", hash = "sha256:712f84f18be94cbe2a148fa4fdf40fcf4a8c25a8f7670efb9f8a47ddec2fc281", size = 288203, upload-time = "2024-11-11T06:59:23.404Z" }, +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/59/e0/e63103c72a9d3dfd89a31e02e660263ad84b7438e5f44ee82e443e65bbde/tree_sitter_javascript-0.25.0.tar.gz", hash = "sha256:329b5414874f0588a98f1c291f1b28138286617aa907746ffe55adfdcf963f38", size = 132338, upload-time = "2025-09-01T07:13:44.792Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/df/5106ac250cd03661ebc3cc75da6b3d9f6800a3606393a0122eca58038104/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:b70f887fb269d6e58c349d683f59fa647140c410cfe2bee44a883b20ec92e3dc", size = 64052, upload-time = "2025-09-01T07:13:36.865Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8f/6b4b2bc90d8ab3955856ce852cc9d1e82c81d7ab9646385f0e75ffd5b5d3/tree_sitter_javascript-0.25.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:8264a996b8845cfce06965152a013b5d9cbb7d199bc3503e12b5682e62bb1de1", size = 66440, upload-time = "2025-09-01T07:13:37.962Z" }, + { url = "https://files.pythonhosted.org/packages/5f/c4/7da74ecdcd8a398f88bd003a87c65403b5fe0e958cdd43fbd5fd4a398fcf/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9dc04ba91fc8583344e57c1f1ed5b2c97ecaaf47480011b92fbeab8dda96db75", size = 99728, upload-time = "2025-09-01T07:13:38.755Z" }, + { url = "https://files.pythonhosted.org/packages/96/c8/97da3af4796495e46421e9344738addb3602fa6426ea695be3fcbadbee37/tree_sitter_javascript-0.25.0-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:199d09985190852e0912da2b8d26c932159be314bc04952cf917ed0e4c633e6b", size = 106072, upload-time = "2025-09-01T07:13:39.798Z" }, + { url = "https://files.pythonhosted.org/packages/13/be/c964e8130be08cc9bd6627d845f0e4460945b158429d39510953bbcb8fcc/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dfcf789064c58dc13c0a4edb550acacfc6f0f280577f1e7a00de3e89fc7f8ddc", size = 104388, upload-time = "2025-09-01T07:13:40.866Z" }, + { url = "https://files.pythonhosted.org/packages/ee/89/9b773dee0f8961d1bb8d7baf0a204ab587618df19897c1ef260916f318ec/tree_sitter_javascript-0.25.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b852d3aee8a36186dbcc32c798b11b4869f9b5041743b63b65c2ef793db7a54", size = 98377, upload-time = "2025-09-01T07:13:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/3b/dc/d90cb1790f8cec9b4878d278ad9faf7c8f893189ce0f855304fd704fc274/tree_sitter_javascript-0.25.0-cp310-abi3-win_amd64.whl", hash = "sha256:e5ed840f5bd4a3f0272e441d19429b26eedc257abe5574c8546da6b556865e3c", size = 62975, upload-time = "2025-09-01T07:13:42.828Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1f/f9eba1038b7d4394410f3c0a6ec2122b590cd7acb03f196e52fa57ebbe72/tree_sitter_javascript-0.25.0-cp310-abi3-win_arm64.whl", hash = "sha256:622a69d677aa7f6ee2931d8c77c981a33f0ebb6d275aa9d43d3397c879a9bb0b", size = 61668, upload-time = "2025-09-01T07:13:43.803Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0"