Add multi-language logic exploration
This commit is contained in:
parent
9b4258c852
commit
9161889492
18 changed files with 1639 additions and 76 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 & 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>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
831
src/docforge/treesitter_logic.py
Normal file
831
src/docforge/treesitter_logic.py
Normal 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]}…"
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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:"))
|
||||
|
|
|
|||
154
tests/test_treesitter_logic.py
Normal file
154
tests/test_treesitter_logic.py
Normal 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()
|
||||
|
|
@ -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
66
uv.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue