1
0
Fork 0
Code Issues Pull requests Projects Releases 2 Packages Wiki Activity Actions Pages

Add multi-language logic exploration

This commit is contained in:
Andraxion 2026-07-25 22:29:15 -04:00
parent 9b4258c852
commit 9161889492
18 changed files with 1639 additions and 76 deletions

View file

@ -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

View file

@ -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

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"]

View file

@ -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;

View file

@ -35,6 +35,34 @@
</div>
<label for="family">Family</label>
<select id="family" name="family"><option value="">All families</option></select>
<div class="filter-grid">
<label>
Node type
<select id="kind" name="kind">
<option value="">All node types</option>
<option value="callable">Callable functions &amp; methods</option>
</select>
</label>
<label>
Language
<select id="language" name="language">
<option value="">All languages</option>
</select>
</label>
</div>
<label for="capability">Capability</label>
<select id="capability" name="capability">
<option value="">All capabilities</option>
<option value="logic">Logic available</option>
<option value="source">Source available</option>
</select>
<div class="filter-presets" role="group" aria-label="Quick node filters">
<button type="button" data-preset="logic">Logic-ready</button>
<button type="button" data-preset="python">Python callables</button>
<button type="button" data-preset="tests">Tests</button>
<button type="button" data-preset="routes">Routes</button>
<button type="button" data-preset="docs">Docs</button>
</div>
</form>
<div class="results-context">
<strong id="results-label">All nodes</strong>

View file

@ -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"));

View file

@ -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:

View file

@ -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 = "<javascript-source>",
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 = "<cpp-source>",
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]}"

View file

@ -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",

View file

@ -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")

View file

@ -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:"))

View file

@ -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()

View file

@ -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,

66
uv.lock generated
View file

@ -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"