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

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