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

Add function-scoped Logic visualization

This commit is contained in:
Andraxion 2026-07-25 21:08:43 -04:00
parent 9fcafc290c
commit 9b4258c852
22 changed files with 1420 additions and 62 deletions

View file

@ -15,7 +15,7 @@ declared manuals, visualizes project structure, and manages reviewable documenta
equivalence checks. equivalence checks.
- Keeps function-scoped control-flow projections separate from the primary architecture graph. - Keeps function-scoped control-flow projections separate from the primary architecture graph.
- Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web, - Runs a managed loopback graph browser with neighborhood, semantic Flow, convergence Web,
source inspection, and branch-aware node hiding. function-scoped Logic, source inspection, and branch-aware node hiding.
- Supports generic documentation projects and project-owned source adapters. - Supports generic documentation projects and project-owned source adapters.
DocForge never treats indexed text as instructions. It does not run shell commands, mutate Git, DocForge never treats indexed text as instructions. It does not run shell commands, mutate Git,
@ -35,7 +35,8 @@ implement the source manifest and extraction methods. Incremental adapters must
## Graph views ## Graph views
The browser presents the same indexed graph through three complementary views: The browser presents the primary architecture graph through three complementary views and loads a
fourth function-scoped view only when requested:
- **Nodes** shows a bounded, relation-neutral neighborhood around the focus. It is the broad - **Nodes** shows a bounded, relation-neutral neighborhood around the focus. It is the broad
inspection view for seeing stored incoming and outgoing relationships without changing their inspection view for seeing stored incoming and outgoing relationships without changing their
@ -46,14 +47,19 @@ The browser presents the same indexed graph through three complementary views:
inheritance, definitions, and tests flow toward the thing they help create or exercise. inheritance, definitions, and tests flow toward the thing they help create or exercise.
- **Web** shows the larger convergence picture: Flow contributors plus contextual relationships, - **Web** shows the larger convergence picture: Flow contributors plus contextual relationships,
callers, containers, and direct members or execution dependencies owned by the focus. callers, containers, and direct members or execution dependencies owned by the focus.
- **Logic** shows the possible static control paths inside a focused Python function or method.
Entry, decisions, actions, loops, merges, returns, and exceptions connect through explicit
`TRUE`, `FALSE`, `NEXT`, `CASE`, `LOOP`, `RETURN`, and `RAISE` paths. Logic is stored separately
and does not add statement-level noise to Nodes, Flow, Web, or search.
Graph cards show the node's readable leaf name and kind without clipping either value. The full 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. qualified identity remains available in the tooltip, compact descriptor, and full inspector.
**Hide node** removes noise without changing the index. In Flow and Web, hiding a contributor also **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 removes upstream ancestors that no longer have a path to the focus. Nodes between the hidden
contributor and the focus stay visible, and alternate ancestor paths remain intact. **Restore contributor and the focus stay visible, and alternate ancestor paths remain intact. In Logic,
hidden** restores the presentation. hiding a step inserts an explicit omitted-path bridge so downstream control flow remains readable.
**Restore hidden** restores the presentation.
## Five-minute start ## Five-minute start

View file

@ -1,5 +1,27 @@
# Completed slices # Completed slices
## Dev-Rewrite function-scoped Logic
### Changed
- Added a reusable Python AST control-flow analyzer for functions, methods, and nested functions.
- Added dedicated schema-2 SQLite tables for function-scoped logic owners, nodes, and edges without
placing statement-level data in primary graph search or traversal.
- Added the bounded `docforge_get_logic` read tool and a lazy Logic visualization tab.
- Added semantic Entry, Decision, Action, Control, Merge, and Terminal cards with explicit branch,
loop, exception, return, and raise paths.
- Added Logic-specific hiding that bridges retained predecessors and successors with an explicit
omitted path.
- Preserved Release 1 adapters and full projections. Adapters may emit no logic or opt in source by
source through the incremental extraction contract.
### Verification
- Tests cover Python branching, compound booleans, loops, `match`, exceptions, nested functions,
schema persistence, bounded reads, MCP registration, visualization APIs, and Logic UI assets.
- Strict Pyright, Ruff, formatting, compilation, warning-strict tests, web linting, package builds,
dependency audits, and browser QA pass.
## Dev-Rewrite incremental compiler boundary ## Dev-Rewrite incremental compiler boundary
### Changed ### Changed

View file

@ -18,8 +18,8 @@ commit when Git is available; it cannot change repository state.
- Edge schema: `schemas/edge.schema.json`, version 1. - Edge schema: `schemas/edge.schema.json`, version 1.
- Result envelope: `schemas/result.schema.json`, version 1. - Result envelope: `schemas/result.schema.json`, version 1.
- Changeset schema: `schemas/changeset.schema.json`, version 1. - Changeset schema: `schemas/changeset.schema.json`, version 1.
- Index schema: version 1, disposable and reproducible. - Index schema: version 2, disposable and reproducible.
- Core, CLI, and MCP server: version 1.1.0.dev0. - Core, CLI, and MCP server: version 1.2.0.dev0.
- Incremental extraction cache: version 1, disposable and reproducible. - Incremental extraction cache: version 1, disposable and reproducible.
Schema files describe the generic interchange contract. Runtime validation remains responsible for Schema files describe the generic interchange contract. Runtime validation remains responsible for
@ -104,13 +104,13 @@ random token is part of every accepted URL path. Only `GET` and `HEAD` are suppo
no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection, no-store caching, a restrictive content-security policy, frame denial, MIME sniffing protection,
and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph and no-referrer policy. The built-in template uses only same-origin JSON endpoints for graph
overview, bounded search, exact descriptor-category filtering, exact node content, bounded overview, bounded search, exact descriptor-category filtering, exact node content, bounded
incoming-and-outgoing neighborhoods, semantic Flow ancestry, convergence Web context, and one incoming-and-outgoing neighborhoods, semantic Flow ancestry, convergence Web context, lazy
node's bounded project-confined source file. function-scoped Logic, and one node's bounded project-confined source file.
Descriptor filtering accepts only Descriptor filtering accepts only
family, authority, status, or tag plus one exact value. There is no write endpoint, arbitrary query 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. endpoint, static filesystem handler, external asset, or project-selection control.
The `graph-browser@15` template provides mouse-wheel zoom centered on the pointer, left-button drag The `graph-browser@16` template provides mouse-wheel zoom centered on the pointer, left-button drag
pan, explicit zoom-in and zoom-out buttons, a reset-view button, and a live zoom percentage. A 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. 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 Loading another root node fits the viewport to the returned neighborhood, including a useful
@ -141,7 +141,7 @@ supported line, TOML, heading, or text anchors. Both side panels support pointer
resizing. The unblurred full inspector supports native resizing, constrained title-bar dragging, resizing. The unblurred full inspector supports native resizing, constrained title-bar dragging,
and a fixed header/footer surrounding a scrollable body. and a fixed header/footer surrounding a scrollable body.
The header exposes a Nodes/Flow/Web segmented selector. Nodes displays the complete bounded The header exposes a Nodes/Flow/Web/Logic segmented selector. Nodes displays the complete bounded
neighborhood. Flow displays semantic ancestry ending at the current root. Structural and execution neighborhood. Flow displays semantic ancestry ending at the current root. Structural and execution
edges retain their declared source-to-target direction. Reads, imports, dependencies, inheritance, edges retain their declared source-to-target direction. Reads, imports, dependencies, inheritance,
and `tested_by` reverse because their declared target feeds or qualifies the source. Documentation and `tested_by` reverse because their declared target feeds or qualifies the source. Documentation
@ -151,10 +151,17 @@ direct root-owned members and execution dependencies into adjacent contributor b
does not fan back out through unrelated siblings. These are presentation transforms over the does not fan back out through unrelated siblings. These are presentation transforms over the
validated snapshot; they do not add or change project relationships. validated snapshot; they do not add or change project relationships.
All three views color edges by relationship semantics and retain direction with visible SVG endpoint Logic is available only when the focused node owns a stored `LogicProjection`. The browser
retrieves that projection through a bounded, exact-owner endpoint. Entry, condition, action,
control, merge, return, raise, and exit nodes remain outside primary graph search and traversal.
Logic edges retain their declared `TRUE`, `FALSE`, `NEXT`, `CASE`, `LOOP`, `EXCEPTION`, `RETURN`,
`RAISE`, `BREAK`, and `CONTINUE` labels. Hiding a logic node creates a visible omitted-path bridge
between retained predecessors and successors instead of pruning valid downstream control flow.
Nodes, Flow, and Web color edges by relationship semantics and retain direction with visible SVG endpoint
symbols. Line patterns provide a non-color cue. A static canvas key shows the exact symbol, color, symbols. Line patterns provide a non-color cue. A static canvas key shows the exact symbol, color,
label, and visible count for each displayed relation, including a deterministic fallback for label, and visible count for each displayed relation, including a deterministic fallback for
project-defined relations. Nodes, Flow, and Web use the same map. project-defined relations. Logic uses a separate fixed control-flow map.
The browser derives node presentation roles only from the returned bounded graph. The current root The browser derives node presentation roles only from the returned bounded graph. The current root
is the focus. In Nodes, nodes reachable through outgoing edges are shown as outgoing paths; the is the focus. In Nodes, nodes reachable through outgoing edges are shown as outgoing paths; the
@ -221,4 +228,5 @@ must also implement `load_projection()` so a clean rebuild and equivalence check
Logic projections are not primary graph nodes. They remain source-scoped, function-owned, Logic projections are not primary graph nodes. They remain source-scoped, function-owned,
independently cached control-flow data so ordinary search, Nodes, Flow, and Web do not become independently cached control-flow data so ordinary search, Nodes, Flow, and Web do not become
statement graphs. statement graphs. Index schema 2 stores them in dedicated owner, node, and edge tables. Reads are
bounded to one exact function or method owner.

View file

@ -131,8 +131,10 @@ raises. Logic edges retain relation, display label, and deterministic ordinal. A
logic empty until they implement a language analyzer. logic empty until they implement a language analyzer.
This boundary prevents thousands of boolean expressions and basic blocks from polluting Nodes, This boundary prevents thousands of boolean expressions and basic blocks from polluting Nodes,
Flow, Web, ordinary search, or architectural traversal. A future Logic view can request one Flow, Web, ordinary search, or architectural traversal. The Logic tab and `docforge_get_logic`
function-scoped projection on demand. request one function-scoped projection on demand. The built-in Python analyzer covers conditions,
short-circuit booleans, loops, `match`, exception paths, returns, and raises. It reports possible
static paths; it does not claim runtime branch outcomes.
## Full rebuilds ## Full rebuilds

View file

@ -15,6 +15,7 @@ canonical applier implementation.
- `docforge_project_info` - `docforge_project_info`
- `docforge_get_contract` - `docforge_get_contract`
- `docforge_get_node` - `docforge_get_node`
- `docforge_get_logic`
- `docforge_search` - `docforge_search`
- `docforge_filter_nodes` - `docforge_filter_nodes`
- `docforge_backlinks` - `docforge_backlinks`
@ -85,14 +86,16 @@ only through the explicit local CLI integration command.
## Visualization boundary ## Visualization boundary
`docforge_visualize` starts the fixed built-in `graph-browser@15` template against the currently `docforge_visualize` starts the fixed built-in `graph-browser@16` template against the currently
validated derived index. It may focus one stable node, run one bounded lexical query, or open the 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. 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 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, 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, node-neighborhood JSON, semantic Flow,
convergence Web, and a bounded project-confined source read for one indexed node. The browser convergence Web, lazy function-scoped Logic, and a bounded project-confined source read for one
indexed node. `docforge_get_logic` and the browser Logic endpoint accept one exact owner node ID and
return only that bounded stored projection. The browser
exposes an exact validated index snapshot. It rejects index exposes an exact validated index snapshot. It rejects index
replacement or alteration and requires another MCP invocation to refresh. replacement or alteration and requires another MCP invocation to refresh.
Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button Viewport interaction is entirely client-side: fitted neighborhood framing, wheel zoom, left-button
@ -113,7 +116,10 @@ tooltips and inspectors. Flow presents semantic ancestry with relation-aware dir
Web follows bounded structural, dependency, execution, evidence, and contextual contributors into Web follows bounded structural, dependency, execution, evidence, and contextual contributors into
the focus. Direct focus-owned members and execution dependencies become adjacent contributor the focus. Direct focus-owned members and execution dependencies become adjacent contributor
branches without expanding unrelated siblings. The relationship key is regenerated from each branches without expanding unrelated siblings. The relationship key is regenerated from each
visible view. The browser runs in a project-bound worker owned by visible view. Logic displays possible static control paths for a focused function or method without
adding its statement-level nodes to primary search or architectural traversal. Hiding a Logic step
bridges its retained predecessors and successors with an explicit omitted path. The browser runs in
a project-bound worker owned by
the separately supervised per-user viewer manager. Standard-input transaction completion and MCP the separately supervised per-user viewer manager. Standard-input transaction completion and MCP
host exit do not close the listener. Repeated visualization requests reuse the current worker while host exit do not close the listener. Repeated visualization requests reuse the current worker while
its exact snapshot remains valid. `docforge_visualization_status` reports lifecycle state, and its exact snapshot remains valid. `docforge_visualization_status` reports lifecycle state, and

View file

@ -296,6 +296,30 @@ Adjacent traversal is deliberately bounded. After DocForge includes a direct mem
dependency owned by the focus, it continues toward that branch rather than fanning back out dependency owned by the focus, it continues toward that branch rather than fanning back out
through unrelated siblings. Depth and edge limits provide a second guard against an unbounded web. through unrelated siblings. Depth and edge limits provide a second guard against an unbounded web.
### Logic: possible control paths
**Logic** answers: “What decisions and actions can occur inside this function or method?”
Logic appears when the focused node owns a function-scoped `LogicProjection`. It loads that
projection on demand instead of adding statements and conditions to the primary architecture
graph. The view presents:
- **Entry** and **Exit** terminals.
- **Decision** cards for `if`, `elif`, compound booleans, loop conditions, `match` cases, and
assertions.
- **Action** cards for executable statement blocks and calls.
- **Control** cards for loops, `break`, and `continue`.
- **Merge** cards where alternate paths converge.
- **Terminal** cards for returns and raised exceptions.
Edges use explicit labels and independent colors for `TRUE`, `FALSE`, `NEXT`, `CASE`, `LOOP`,
`EXCEPTION`, `RETURN`, `RAISE`, `BREAK`, and `CONTINUE`. Long predicates wrap on the card. The full
expression and source anchor remain available through inspection and source navigation.
Logic is static analysis. It shows paths the indexed source permits, not the branch that ran for a
particular request or the runtime value of a boolean. Dynamic dispatch, reflection, generated
behavior, and values returned by other processes may require runtime tracing to resolve.
### Reading graph cards ### Reading graph cards
The canvas presents nodes as compact semantic cards rather than anonymous circles: The canvas presents nodes as compact semantic cards rather than anonymous circles:
@ -332,6 +356,9 @@ index, or future graph queries. The focus cannot be hidden; focus another node f
- In **Nodes**, hiding removes only the selected node and its incident edges. - In **Nodes**, hiding removes only the selected node and its incident edges.
- In **Flow** and **Web**, hiding removes the selected node, then prunes every upstream ancestor - In **Flow** and **Web**, hiding removes the selected node, then prunes every upstream ancestor
whose only remaining route to the focus passed through it. whose only remaining route to the focus passed through it.
- In **Logic**, hiding removes the selected control-flow step and inserts an `omitted` bridge
between its visible predecessors and successors. This preserves the readable path without
pretending the hidden code disappeared from the indexed source.
- Descendant nodes between the hidden node and the focus remain visible. - Descendant nodes between the hidden node and the focus remain visible.
- Ancestors with another valid path to the focus remain visible through that alternate path. - Ancestors with another valid path to the focus remain visible through that alternate path.
- The status line reports how many nodes were hidden or isolated. - The status line reports how many nodes were hidden or isolated.
@ -452,6 +479,7 @@ Example MCP client configuration:
- `docforge_project_info` - `docforge_project_info`
- `docforge_get_contract` - `docforge_get_contract`
- `docforge_get_node` - `docforge_get_node`
- `docforge_get_logic`
- `docforge_search` - `docforge_search`
- `docforge_filter_nodes` - `docforge_filter_nodes`
- `docforge_backlinks` - `docforge_backlinks`
@ -523,8 +551,9 @@ canonical sources first. Incremental compilation then notices those changed sour
it never treats an unapplied proposal as canonical. it never treats an unapplied proposal as canonical.
Function-scoped `LogicProjection` data is cached alongside its owning source but remains separate Function-scoped `LogicProjection` data is cached alongside its owning source but remains separate
from the primary Nodes, Flow, and Web graph. This is the storage boundary for a future boolean and from the primary Nodes, Flow, and Web graph. The Logic tab and `docforge_get_logic` load one
control-flow view without adding every condition and basic block to ordinary graph traversal. function or method on demand without adding every condition and basic block to ordinary graph
traversal.
See [Incremental Adapter Indexing](INCREMENTAL_INDEXING.md) for the complete contract, cache See [Incremental Adapter Indexing](INCREMENTAL_INDEXING.md) for the complete contract, cache
invalidation rules, manual-application lifecycle, and lazy Logic boundary. invalidation rules, manual-application lifecycle, and lazy Logic boundary.
@ -606,7 +635,7 @@ ambiguous adapter evidence.
### Full inspector content does not fit ### 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 DocForge 1.0 uses a fixed header and footer with a scrollable inspector body. If an older page is
still open, stop and reopen the visualization so it loads the current `graph-browser@15` template. still open, stop and reopen the visualization so it loads the current `graph-browser@16` template.
### Render output is stale ### Render output is stale

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "docforge" name = "docforge"
version = "1.1.0.dev0" version = "1.2.0.dev0"
description = "Project-scoped documentation indexing and context service" description = "Project-scoped documentation indexing and context service"
readme = "README.md" readme = "README.md"
requires-python = ">=3.12" requires-python = ">=3.12"

View file

@ -11,4 +11,4 @@ __all__ = [
"GenericCanonicalApplier", "GenericCanonicalApplier",
"Project", "Project",
] ]
__version__ = "1.1.0.dev0" __version__ = "1.2.0.dev0"

View file

@ -405,6 +405,11 @@ class AdapterProject:
None, None,
) )
def logic_projections(self) -> tuple[LogicProjection, ...]:
"""Return logic captured by the most recent validated project load."""
return self._last_logic
def verify_incremental_equivalence(self) -> dict[str, object]: def verify_incremental_equivalence(self) -> dict[str, object]:
"""Prove the incremental and full loader contracts produce the same graph.""" """Prove the incremental and full loader contracts produce the same graph."""

View file

@ -26,7 +26,7 @@ header {
} }
header h1 { margin: 0; font-size: 17px; } header h1 { margin: 0; font-size: 17px; }
.view-switch { .view-switch {
position: relative; display: grid; grid-template-columns: repeat(3, 58px); position: relative; display: grid; grid-template-columns: repeat(4, 58px);
flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px; flex: 0 0 auto; padding: 3px; border: 1px solid var(--line); border-radius: 9px;
background: #08131f; isolation: isolate; background: #08131f; isolation: isolate;
} }
@ -38,6 +38,7 @@ header h1 { margin: 0; font-size: 17px; }
} }
.view-switch[data-mode="flow"]::before { transform: translateX(58px); } .view-switch[data-mode="flow"]::before { transform: translateX(58px); }
.view-switch[data-mode="web"]::before { transform: translateX(116px); } .view-switch[data-mode="web"]::before { transform: translateX(116px); }
.view-switch[data-mode="logic"]::before { transform: translateX(174px); }
.view-switch button { .view-switch button {
min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px; min-height: 30px; border: 0; border-radius: 6px; padding: 4px 8px;
background: transparent; color: var(--muted); font-size: 12px; font-weight: 700; background: transparent; color: var(--muted); font-size: 12px; font-weight: 700;

View file

@ -15,6 +15,7 @@
<button id="view-nodes" type="button" aria-pressed="true">Nodes</button> <button id="view-nodes" type="button" aria-pressed="true">Nodes</button>
<button id="view-flow" type="button" aria-pressed="false">Flow</button> <button id="view-flow" type="button" aria-pressed="false">Flow</button>
<button id="view-web" type="button" aria-pressed="false">Web</button> <button id="view-web" type="button" aria-pressed="false">Web</button>
<button id="view-logic" type="button" aria-pressed="false">Logic</button>
</div> </div>
<h1 id="project-title">DocForge graph</h1> <h1 id="project-title">DocForge graph</h1>
<div class="stats"> <div class="stats">

View file

@ -4,6 +4,7 @@ const state = {
overview: null, overview: null,
graph: null, graph: null,
root: null, root: null,
focusNode: null,
mode: "nodes", mode: "nodes",
depth: 1, depth: 1,
searchLimit: 1, searchLimit: 1,
@ -95,6 +96,50 @@ const relationStyles = Object.freeze({
family: "Context", color: "#94a3b8", dash: "5 5", marker: "open-arrow", family: "Context", color: "#94a3b8", dash: "5 5", marker: "open-arrow",
flow: null, flow: null,
}, },
next: {
family: "Logic", color: "#8da2b8", dash: "", marker: "arrow",
flow: "forward",
},
when_true: {
family: "Logic", color: "#4ade80", dash: "", marker: "arrow",
flow: "forward",
},
when_false: {
family: "Logic", color: "#fb7185", dash: "5 3", marker: "arrow",
flow: "forward",
},
case: {
family: "Logic", color: "#c084fc", dash: "7 3", marker: "arrow",
flow: "forward",
},
loop: {
family: "Logic", color: "#2dd4bf", dash: "4 3", marker: "double-arrow",
flow: "forward",
},
exception: {
family: "Logic", color: "#f97316", dash: "3 3", marker: "open-arrow",
flow: "forward",
},
return: {
family: "Logic", color: "#38bdf8", dash: "", marker: "square-arrow",
flow: "forward",
},
raise: {
family: "Logic", color: "#f43f5e", dash: "", marker: "square-arrow",
flow: "forward",
},
break: {
family: "Logic", color: "#fbbf24", dash: "6 3", marker: "open-arrow",
flow: "forward",
},
continue: {
family: "Logic", color: "#22d3ee", dash: "6 3", marker: "open-arrow",
flow: "forward",
},
omitted: {
family: "Logic", color: "#64748b", dash: "2 5", marker: "open-arrow",
flow: "forward",
},
}); });
const contributionStyles = Object.freeze({ const contributionStyles = Object.freeze({
focus: { focus: {
@ -126,10 +171,30 @@ const contributionStyles = Object.freeze({
related: { related: {
label: "Related", section: "Other connections", color: "#fb923c", fill: "#3b2719", label: "Related", section: "Other connections", color: "#fb923c", fill: "#3b2719",
}, },
"logic-entry": {
label: "Entry", section: "Function boundary", color: "#67e8f9", fill: "#103745",
},
"logic-condition": {
label: "Decision", section: "Conditions & cases", color: "#facc15", fill: "#3b3112",
},
"logic-action": {
label: "Action", section: "Actions & calls", color: "#34d399", fill: "#15372e",
},
"logic-control": {
label: "Control", section: "Loops & exception handling", color: "#c084fc", fill: "#302044",
},
"logic-merge": {
label: "Merge", section: "Branch convergence", color: "#94a3b8", fill: "#252d39",
},
"logic-terminal": {
label: "Terminal", section: "Returns, raises & exits", color: "#fb7185", fill: "#41202a",
},
}); });
const contributionOrder = Object.freeze([ const contributionOrder = Object.freeze([
"focus", "composition", "behavior", "dependency", "execution", "focus", "composition", "behavior", "dependency", "execution",
"data", "evidence", "context", "related", "data", "evidence", "context", "related",
"logic-entry", "logic-condition", "logic-action", "logic-control",
"logic-merge", "logic-terminal",
]); ]);
const compositionRelations = new Set(["contains", "defines", "defined_in"]); const compositionRelations = new Set(["contains", "defines", "defined_in"]);
const behaviorRelations = new Set(["inherits", "implemented_by"]); const behaviorRelations = new Set(["inherits", "implemented_by"]);
@ -193,12 +258,17 @@ function humanize(value) {
} }
function nodeDisplayName(node) { function nodeDisplayName(node) {
const title = escapeText(node.title).trim() || escapeText(node.node_id); const title = escapeText(node.title).trim() || escapeText(node.node_id);
if (Array.isArray(node.tags) && node.tags.includes("logic")) return title;
if (!title || /\s/.test(title)) return title; if (!title || /\s/.test(title)) return title;
const parts = title.split(/::|[./]/).filter(Boolean); const parts = title.split(/::|[./]/).filter(Boolean);
return parts.at(-1) || title; return parts.at(-1) || title;
} }
function nodeKindLabel(node) { function nodeKindLabel(node) {
const tags = new Set(Array.isArray(node.tags) ? node.tags.map(String) : []); const tags = new Set(Array.isArray(node.tags) ? node.tags.map(String) : []);
if (tags.has("logic")) {
const kind = [...tags].find((tag) => tag !== "logic");
return kind ? humanize(kind) : "Logic";
}
const kinds = [ const kinds = [
"method", "function", "class", "module", "package", "property", "field", "method", "function", "class", "module", "package", "property", "field",
"route", "command", "service", "plugin", "table", "column", "view", "route", "command", "service", "plugin", "table", "column", "view",
@ -458,6 +528,7 @@ function restoreGraphStatus() {
nodes: "neighborhood", nodes: "neighborhood",
flow: "semantic flow", flow: "semantic flow",
web: "convergence web", web: "convergence web",
logic: "control flow",
}[state.mode]; }[state.mode];
const pruned = state.prunedCount ? ` · ${state.prunedCount} hidden or isolated` : ""; const pruned = state.prunedCount ? ` · ${state.prunedCount} hidden or isolated` : "";
setStatus(`${state.visibleNodeCount} nodes · ${state.visibleEdgeCount} edges in ${scope}${pruned}`); setStatus(`${state.visibleNodeCount} nodes · ${state.visibleEdgeCount} edges in ${scope}${pruned}`);
@ -623,6 +694,40 @@ function buildWebGraph(data) {
])); ]));
return {...data, nodes, edges, topology}; return {...data, nodes, edges, topology};
} }
function buildLogicGraph(data) {
const nodeIds = new Set(data.nodes.map((node) => node.node_id));
const edges = data.edges.filter(
(edge) => nodeIds.has(edge.source_id) && nodeIds.has(edge.target_id),
);
const hops = new Map([[data.root, 0]]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const sourceId of frontier) {
for (const edge of edges) {
if (edge.source_id !== sourceId || hops.has(edge.target_id)) continue;
hops.set(edge.target_id, hops.get(sourceId) + 1);
next.push(edge.target_id);
}
}
frontier = next;
}
const nodes = data.nodes.filter((node) => hops.has(node.node_id));
return {
...data,
nodes,
edges: edges.filter(
(edge) => hops.has(edge.source_id) && hops.has(edge.target_id),
),
topology: new Map(nodes.map((node) => [
node.node_id,
{
hop: hops.get(node.node_id) ?? 0,
role: node.node_id === data.root ? "primary" : "child",
},
])),
};
}
function pruneConvergenceGraph(data, hiddenNodes) { function pruneConvergenceGraph(data, hiddenNodes) {
const candidates = new Set( const candidates = new Set(
data.nodes data.nodes
@ -656,7 +761,92 @@ function pruneConvergenceGraph(data, hiddenNodes) {
prunedCount: data.nodes.length - reachesFocus.size, prunedCount: data.nodes.length - reachesFocus.size,
}; };
} }
function pruneLogicGraph(data, hiddenNodes) {
const visible = new Set(
data.nodes
.filter((node) => node.node_id === data.root || !hiddenNodes.has(node.node_id))
.map((node) => node.node_id),
);
const outgoing = new Map(data.nodes.map((node) => [node.node_id, []]));
for (const edge of data.edges) outgoing.get(edge.source_id)?.push(edge);
const edges = [];
const keys = new Set();
const append = (edge) => {
const key = `${edge.source_id}\u0000${edge.relation}\u0000${edge.target_id}`;
if (keys.has(key)) return;
keys.add(key);
edges.push(edge);
};
for (const sourceId of visible) {
const stack = [...(outgoing.get(sourceId) || [])].map(
(edge) => ({edge, omitted: false, seen: new Set([sourceId])}),
);
while (stack.length) {
const current = stack.pop();
const targetId = current.edge.target_id;
if (current.seen.has(targetId)) continue;
const seen = new Set(current.seen);
seen.add(targetId);
if (visible.has(targetId)) {
append(current.omitted
? {
source_id: sourceId,
relation: "omitted",
target_id: targetId,
label: "HIDDEN PATH",
reversed: false,
}
: current.edge);
continue;
}
for (const nextEdge of outgoing.get(targetId) || []) {
stack.push({edge: nextEdge, omitted: true, seen});
}
}
}
const reachable = new Set([data.root]);
let frontier = [data.root];
while (frontier.length) {
const next = [];
for (const sourceId of frontier) {
for (const edge of edges) {
if (edge.source_id !== sourceId || reachable.has(edge.target_id)) continue;
reachable.add(edge.target_id);
next.push(edge.target_id);
}
}
frontier = next;
}
const nodes = data.nodes.filter((node) => reachable.has(node.node_id));
return {
...data,
nodes,
edges: edges.filter(
(edge) => reachable.has(edge.source_id) && reachable.has(edge.target_id),
),
topology: buildLogicGraph({
...data,
nodes,
edges,
}).topology,
prunedCount: data.nodes.length - nodes.length,
};
}
function nodeContributionCategory(nodeId, data, topology) { function nodeContributionCategory(nodeId, data, topology) {
const node = data.nodes.find((candidate) => candidate.node_id === nodeId);
if (Array.isArray(node?.tags) && node.tags.includes("logic")) {
const kind = escapeText(node.logic_kind
|| node.tags.find((tag) => tag !== "logic")).toLowerCase();
if (kind === "entry") return "logic-entry";
if (["condition", "case"].includes(kind)) return "logic-condition";
if (["action", "call"].includes(kind)) return "logic-action";
if (["loop", "try", "except", "finally", "break", "continue"].includes(kind)) {
return "logic-control";
}
if (kind === "merge") return "logic-merge";
if (["return", "raise", "exit"].includes(kind)) return "logic-terminal";
return "logic-action";
}
if (nodeId === data.root) return "focus"; if (nodeId === data.root) return "focus";
const nodeHop = topology.get(nodeId)?.hop ?? Number.POSITIVE_INFINITY; const nodeHop = topology.get(nodeId)?.hop ?? Number.POSITIVE_INFINITY;
const candidates = []; const candidates = [];
@ -756,6 +946,14 @@ function layoutFlow(nodes, rootId, topology, sizes = nodeSizeMap(nodes, rootId))
} }
return positions; return positions;
} }
function layoutLogic(nodes, rootId, topology, sizes = nodeSizeMap(nodes, rootId)) {
const positions = layoutFlow(nodes, rootId, topology, sizes);
for (const [nodeId, point] of positions) {
if (nodeId === rootId) continue;
positions.set(nodeId, {...point, x: Math.abs(point.x)});
}
return positions;
}
function darken(hex, amount) { function darken(hex, amount) {
const value = Number.parseInt(hex.slice(1), 16); const value = Number.parseInt(hex.slice(1), 16);
const factor = 1 - Math.min(.5, Math.max(0, amount)); const factor = 1 - Math.min(.5, Math.max(0, amount));
@ -798,6 +996,7 @@ function renderNeighborhood(data, topology, categories) {
nodes: "Neighborhood", nodes: "Neighborhood",
flow: "Semantic flow", flow: "Semantic flow",
web: "Convergence web", web: "Convergence web",
logic: "Control flow",
}[state.mode]; }[state.mode];
renderNodeLegend(categories); renderNodeLegend(categories);
const container = $("neighborhood-sections"); const container = $("neighborhood-sections");
@ -825,7 +1024,9 @@ function renderNeighborhood(data, topology, categories) {
button.type = "button"; button.type = "button";
button.className = "node-list-item"; button.className = "node-list-item";
button.style.setProperty("--item-color", palette.stroke); button.style.setProperty("--item-color", palette.stroke);
button.title = `Focus ${node.title}`; button.title = state.mode === "logic"
? `Inspect ${node.title}`
: `Focus ${node.title}`;
const swatch = document.createElement("i"); const swatch = document.createElement("i");
swatch.className = "node-swatch"; swatch.className = "node-swatch";
const copy = document.createElement("span"); const copy = document.createElement("span");
@ -837,7 +1038,14 @@ function renderNeighborhood(data, topology, categories) {
meta.textContent = `${nodeKindLabel(node)} · ${style.label} · ${hopLabel}`; meta.textContent = `${nodeKindLabel(node)} · ${style.label} · ${hopLabel}`;
copy.append(title, meta); copy.append(title, meta);
button.append(swatch, copy); button.append(swatch, copy);
button.addEventListener("click", () => loadNode(node.node_id)); button.addEventListener("click", (event) => {
if (state.mode === "logic") {
selectNode(node.node_id);
showNodeCard(node.node_id, event);
} else {
loadNode(node.node_id);
}
});
list.append(button); list.append(button);
} }
container.append(heading, list); container.append(heading, list);
@ -906,7 +1114,9 @@ function renderGraph(data, preserveSelection = false) {
: data.root; : data.root;
const completeView = state.mode === "flow" const completeView = state.mode === "flow"
? buildFlowGraph(data) ? buildFlowGraph(data)
: state.mode === "web" ? buildWebGraph(data) : data; : state.mode === "web"
? buildWebGraph(data)
: state.mode === "logic" ? buildLogicGraph(data) : data;
let view; let view;
if (state.mode === "nodes") { if (state.mode === "nodes") {
const visibleIds = new Set( const visibleIds = new Set(
@ -923,6 +1133,8 @@ function renderGraph(data, preserveSelection = false) {
), ),
prunedCount: completeView.nodes.length - visibleIds.size, prunedCount: completeView.nodes.length - visibleIds.size,
}; };
} else if (state.mode === "logic") {
view = pruneLogicGraph(completeView, state.hiddenNodes);
} else { } else {
view = pruneConvergenceGraph(completeView, state.hiddenNodes); view = pruneConvergenceGraph(completeView, state.hiddenNodes);
} }
@ -945,9 +1157,11 @@ function renderGraph(data, preserveSelection = false) {
const topology = view.topology || analyzeTopology(view); const topology = view.topology || analyzeTopology(view);
const categories = nodeCategoryMap(view, topology); const categories = nodeCategoryMap(view, topology);
const sizes = nodeSizeMap(view.nodes, view.root); const sizes = nodeSizeMap(view.nodes, view.root);
const positions = state.mode !== "nodes" const positions = state.mode === "logic"
? layoutFlow(view.nodes, view.root, topology, sizes) ? layoutLogic(view.nodes, view.root, topology, sizes)
: layoutNodes(view.nodes, view.root, topology, sizes); : state.mode !== "nodes"
? layoutFlow(view.nodes, view.root, topology, sizes)
: layoutNodes(view.nodes, view.root, topology, sizes);
state.positions = positions; state.positions = positions;
state.homeViewport = viewportForPositions(positions, sizes); state.homeViewport = viewportForPositions(positions, sizes);
resetViewport(); resetViewport();
@ -986,7 +1200,7 @@ function renderGraph(data, preserveSelection = false) {
fill: style.color, fill: style.color,
"text-anchor": "middle", "text-anchor": "middle",
}); });
label.textContent = relationLabel(edge.relation, edge.reversed); label.textContent = edge.label || relationLabel(edge.relation, edge.reversed);
edgeLayer.append(label); edgeLayer.append(label);
} }
for (const node of view.nodes) { for (const node of view.nodes) {
@ -1093,6 +1307,10 @@ function hideNode(nodeId) {
: ""; : "";
setStatus(`Hidden ${nodeId}.${suffix} Restore hidden nodes from the graph controls.`); setStatus(`Hidden ${nodeId}.${suffix} Restore hidden nodes from the graph controls.`);
} }
function explorationTarget(nodeId) {
const node = state.graph?.nodes.find((candidate) => candidate.node_id === nodeId);
return escapeText(node?.logic_owner_id || nodeId);
}
function restoreHiddenNodes() { function restoreHiddenNodes() {
const count = state.hiddenNodes.size; const count = state.hiddenNodes.size;
state.hiddenNodes.clear(); state.hiddenNodes.clear();
@ -1334,10 +1552,14 @@ async function loadNode(nodeId) {
try { try {
const showingFlow = state.mode === "flow"; const showingFlow = state.mode === "flow";
const showingWeb = state.mode === "web"; const showingWeb = state.mode === "web";
const showingLogic = state.mode === "logic";
state.focusNode = nodeId;
const action = showingFlow ? "Tracing semantic flow for" const action = showingFlow ? "Tracing semantic flow for"
: showingWeb ? "Building convergence web for" : "Loading"; : showingWeb ? "Building convergence web for"
: showingLogic ? "Tracing control flow for" : "Loading";
setStatus(`${action} ${nodeId}`); setStatus(`${action} ${nodeId}`);
const endpoint = showingFlow ? "lineage" : showingWeb ? "web" : "node"; const endpoint = showingFlow ? "lineage"
: showingWeb ? "web" : showingLogic ? "logic" : "node";
const params = new URLSearchParams( const params = new URLSearchParams(
showingFlow showingFlow
? {id: nodeId, limit: "1000"} ? {id: nodeId, limit: "1000"}
@ -1347,16 +1569,20 @@ async function loadNode(nodeId) {
depth: String(state.depth), depth: String(state.depth),
limit: "1000", limit: "1000",
} }
: {id: nodeId, depth: String(state.depth), limit: "100"}, : showingLogic
? {id: nodeId}
: {id: nodeId, depth: String(state.depth), limit: "100"},
); );
const data = await api(`${endpoint}?${params}`); const data = await api(`${endpoint}?${params}`);
renderGraph(data); renderGraph(data);
const scope = showingFlow ? "semantic flow" const scope = showingFlow ? "semantic flow"
: showingWeb ? "convergence web" : "neighborhood"; : showingWeb ? "convergence web"
: showingLogic ? "control flow" : "neighborhood";
const suffix = data.truncated ? " · truncated at the safety limit" : ""; const suffix = data.truncated ? " · truncated at the safety limit" : "";
setStatus( const status = showingLogic && !data.available
`${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`, ? `No indexed Python logic is available for ${nodeId}`
); : `${data.nodes.length} nodes · ${data.edges.length} edges in ${scope}${suffix}`;
setStatus(status, showingLogic && !data.available);
history.replaceState( history.replaceState(
null, null,
"", "",
@ -1367,13 +1593,14 @@ async function loadNode(nodeId) {
} }
} }
async function setViewMode(mode) { async function setViewMode(mode) {
if (!["nodes", "flow", "web"].includes(mode)) return; if (!["nodes", "flow", "web", "logic"].includes(mode)) return;
state.mode = mode; state.mode = mode;
$("view-switch").dataset.mode = mode; $("view-switch").dataset.mode = mode;
$("view-nodes").setAttribute("aria-pressed", String(mode === "nodes")); $("view-nodes").setAttribute("aria-pressed", String(mode === "nodes"));
$("view-flow").setAttribute("aria-pressed", String(mode === "flow")); $("view-flow").setAttribute("aria-pressed", String(mode === "flow"));
$("view-web").setAttribute("aria-pressed", String(mode === "web")); $("view-web").setAttribute("aria-pressed", String(mode === "web"));
if (state.root) await loadNode(state.root); $("view-logic").setAttribute("aria-pressed", String(mode === "logic"));
if (state.focusNode) await loadNode(state.focusNode);
} }
function clamp(value, minimum, maximum) { function clamp(value, minimum, maximum) {
return Math.min(maximum, Math.max(minimum, value)); return Math.min(maximum, Math.max(minimum, value));
@ -1474,6 +1701,7 @@ $("clear-result-filter").addEventListener("click", () => {
$("view-nodes").addEventListener("click", () => setViewMode("nodes")); $("view-nodes").addEventListener("click", () => setViewMode("nodes"));
$("view-flow").addEventListener("click", () => setViewMode("flow")); $("view-flow").addEventListener("click", () => setViewMode("flow"));
$("view-web").addEventListener("click", () => setViewMode("web")); $("view-web").addEventListener("click", () => setViewMode("web"));
$("view-logic").addEventListener("click", () => setViewMode("logic"));
$("zoom-in").addEventListener("click", () => zoomAt(.8)); $("zoom-in").addEventListener("click", () => zoomAt(.8));
$("zoom-out").addEventListener("click", () => zoomAt(1.25)); $("zoom-out").addEventListener("click", () => zoomAt(1.25));
$("reset-view").addEventListener("click", resetViewport); $("reset-view").addEventListener("click", resetViewport);
@ -1491,7 +1719,7 @@ $("node-dialog").querySelector(".dialog-head").addEventListener("pointercancel",
$("explore-node").addEventListener("click", async () => { $("explore-node").addEventListener("click", async () => {
const nodeId = state.inspectedNode; const nodeId = state.inspectedNode;
closeNodeDialog(); closeNodeDialog();
if (nodeId) await loadNode(nodeId); if (nodeId) await loadNode(explorationTarget(nodeId));
}); });
$("open-node-source").addEventListener("click", () => { $("open-node-source").addEventListener("click", () => {
if (state.inspectedNode) openSource(state.inspectedNode); if (state.inspectedNode) openSource(state.inspectedNode);
@ -1508,7 +1736,7 @@ $("hide-card-node").addEventListener("click", () => {
$("explore-card-node").addEventListener("click", async () => { $("explore-card-node").addEventListener("click", async () => {
const nodeId = state.cardNode; const nodeId = state.cardNode;
closeNodeCard(); closeNodeCard();
if (nodeId) await loadNode(nodeId); if (nodeId) await loadNode(explorationTarget(nodeId));
}); });
$("node-dialog").addEventListener("click", (event) => { $("node-dialog").addEventListener("click", (event) => {
if (event.target !== $("node-dialog")) return; if (event.target !== $("node-dialog")) return;
@ -1607,7 +1835,9 @@ applyViewport();
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
state.depth = Math.max(1, Number(params.get("depth")) || 1); state.depth = Math.max(1, Number(params.get("depth")) || 1);
const requestedView = params.get("view"); const requestedView = params.get("view");
setViewMode(["flow", "web"].includes(requestedView) ? requestedView : "nodes"); setViewMode(
["flow", "web", "logic"].includes(requestedView) ? requestedView : "nodes",
);
const overview = await api("overview"); const overview = await api("overview");
renderOverview(overview); renderOverview(overview);
startViewerLease(); startViewerLease();

View file

@ -17,6 +17,10 @@ from .models import (
BuildReportingProject, BuildReportingProject,
Edge, Edge,
IncrementalStateProject, IncrementalStateProject,
LogicEdge,
LogicNode,
LogicProject,
LogicProjection,
Node, Node,
ProjectService, ProjectService,
ProjectSnapshot, ProjectSnapshot,
@ -24,7 +28,7 @@ from .models import (
) )
from .project import project_root_fingerprint from .project import project_root_fingerprint
INDEX_SCHEMA_VERSION = 1 INDEX_SCHEMA_VERSION = 2
APPLICATION_ID = 1_146_683_778 APPLICATION_ID = 1_146_683_778
@ -42,6 +46,15 @@ def _edge_hash(edges: tuple[Edge, ...]) -> str:
return hashlib.sha256(payload).hexdigest() return hashlib.sha256(payload).hexdigest()
def _logic_hash(projections: tuple[LogicProjection, ...]) -> str:
payload = json.dumps(
[projection.as_dict() for projection in projections],
sort_keys=True,
separators=(",", ":"),
).encode()
return hashlib.sha256(payload).hexdigest()
def _connect_read_only(path: Path) -> sqlite3.Connection: def _connect_read_only(path: Path) -> sqlite3.Connection:
if not path.is_file(): if not path.is_file():
raise DocForgeError("missing_index", "Derived index does not exist; run build first") raise DocForgeError("missing_index", "Derived index does not exist; run build first")
@ -65,7 +78,10 @@ def _read_connection(path: Path) -> Generator[sqlite3.Connection, None, None]:
connection.close() connection.close()
def _status(snapshot: ProjectSnapshot) -> dict[str, object]: def _status(
snapshot: ProjectSnapshot,
logic: tuple[LogicProjection, ...],
) -> dict[str, object]:
return { return {
"project_id": snapshot.descriptor.project_id, "project_id": snapshot.descriptor.project_id,
"project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root), "project_root_fingerprint": project_root_fingerprint(snapshot.descriptor.root),
@ -75,6 +91,10 @@ def _status(snapshot: ProjectSnapshot) -> dict[str, object]:
"node_count": len(snapshot.nodes), "node_count": len(snapshot.nodes),
"edge_hash": _edge_hash(snapshot.edges), "edge_hash": _edge_hash(snapshot.edges),
"edge_count": len(snapshot.edges), "edge_count": len(snapshot.edges),
"logic_hash": _logic_hash(logic),
"logic_projection_count": len(logic),
"logic_node_count": sum(len(projection.nodes) for projection in logic),
"logic_edge_count": sum(len(projection.edges) for projection in logic),
"index_schema_version": INDEX_SCHEMA_VERSION, "index_schema_version": INDEX_SCHEMA_VERSION,
"adapter": snapshot.descriptor.adapter, "adapter": snapshot.descriptor.adapter,
"status": "ok", "status": "ok",
@ -93,7 +113,8 @@ class ProjectIndex:
def build(self) -> dict[str, object]: def build(self) -> dict[str, object]:
snapshot = self.project.load() snapshot = self.project.load()
status = _status(snapshot) logic = self._logic_projections()
status = _status(snapshot, logic)
build_report = ( build_report = (
self.project.build_report() if isinstance(self.project, BuildReportingProject) else None self.project.build_report() if isinstance(self.project, BuildReportingProject) else None
) )
@ -133,6 +154,32 @@ class ProjectIndex:
PRIMARY KEY (source_id, relation, target_id) PRIMARY KEY (source_id, relation, target_id)
); );
CREATE INDEX edges_target ON edges(target_id, relation, source_id); CREATE INDEX edges_target ON edges(target_id, relation, source_id);
CREATE TABLE logic_owners (
owner_node_id TEXT PRIMARY KEY,
source_id TEXT NOT NULL
);
CREATE TABLE logic_nodes (
owner_node_id TEXT NOT NULL,
logic_id TEXT NOT NULL,
kind TEXT NOT NULL,
label TEXT NOT NULL,
source_anchor TEXT,
PRIMARY KEY (owner_node_id, logic_id)
);
CREATE INDEX logic_nodes_id ON logic_nodes(logic_id, owner_node_id);
CREATE TABLE logic_edges (
owner_node_id TEXT NOT NULL,
source_id TEXT NOT NULL,
relation TEXT NOT NULL,
target_id TEXT NOT NULL,
label TEXT,
ordinal INTEGER NOT NULL,
PRIMARY KEY (
owner_node_id, source_id, ordinal, relation, target_id
)
);
CREATE INDEX logic_edges_target
ON logic_edges(owner_node_id, target_id, source_id);
CREATE VIRTUAL TABLE node_fts USING fts5( CREATE VIRTUAL TABLE node_fts USING fts5(
node_id UNINDEXED, title, summary, content, tags node_id UNINDEXED, title, summary, content, tags
); );
@ -167,6 +214,39 @@ class ProjectIndex:
"INSERT INTO edges VALUES (?, ?, ?)", "INSERT INTO edges VALUES (?, ?, ?)",
[(edge.source_id, edge.relation, edge.target_id) for edge in snapshot.edges], [(edge.source_id, edge.relation, edge.target_id) for edge in snapshot.edges],
) )
connection.executemany(
"INSERT INTO logic_owners VALUES (?, ?)",
[(projection.owner_node_id, projection.source_id) for projection in logic],
)
connection.executemany(
"INSERT INTO logic_nodes VALUES (?, ?, ?, ?, ?)",
[
(
projection.owner_node_id,
node.logic_id,
node.kind,
node.label,
node.source_anchor,
)
for projection in logic
for node in projection.nodes
],
)
connection.executemany(
"INSERT INTO logic_edges VALUES (?, ?, ?, ?, ?, ?)",
[
(
projection.owner_node_id,
edge.source_id,
edge.relation,
edge.target_id,
edge.label,
edge.ordinal,
)
for projection in logic
for edge in projection.edges
],
)
connection.executemany( connection.executemany(
"INSERT INTO node_fts VALUES (?, ?, ?, ?, ?)", "INSERT INTO node_fts VALUES (?, ?, ?, ?, ?)",
[ [
@ -187,7 +267,12 @@ class ProjectIndex:
finally: finally:
connection.close() connection.close()
current = self.project.load() current = self.project.load()
if current.source_hash != snapshot.source_hash or current.revision != snapshot.revision: current_logic = self._logic_projections()
if (
current.source_hash != snapshot.source_hash
or current.revision != snapshot.revision
or current_logic != logic
):
raise DocForgeError("source_changed", "Canonical source changed during index build") raise DocForgeError("source_changed", "Canonical source changed during index build")
os.replace(temporary, self.path) os.replace(temporary, self.path)
except sqlite3.Error as error: except sqlite3.Error as error:
@ -201,13 +286,19 @@ class ProjectIndex:
result["build"] = build_report result["build"] = build_report
return result return result
def _logic_projections(self) -> tuple[LogicProjection, ...]:
if isinstance(self.project, LogicProject):
return self.project.logic_projections()
return ()
def check(self) -> dict[str, object]: def check(self) -> dict[str, object]:
if isinstance(self.project, IncrementalStateProject): if isinstance(self.project, IncrementalStateProject):
state = self.project.incremental_state() state = self.project.incremental_state()
if state is not None: if state is not None:
return self._check_incremental_state(state) return self._check_incremental_state(state)
snapshot = self.project.load() snapshot = self.project.load()
expected = _status(snapshot) logic = self._logic_projections()
expected = _status(snapshot, logic)
with _read_connection(self.path) as connection: with _read_connection(self.path) as connection:
application_id = connection.execute("PRAGMA application_id").fetchone()[0] application_id = connection.execute("PRAGMA application_id").fetchone()[0]
schema_version = connection.execute("PRAGMA user_version").fetchone()[0] schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
@ -223,6 +314,10 @@ class ProjectIndex:
"node_count", "node_count",
"edge_hash", "edge_hash",
"edge_count", "edge_count",
"logic_hash",
"logic_projection_count",
"logic_node_count",
"logic_edge_count",
"index_schema_version", "index_schema_version",
"adapter", "adapter",
): ):
@ -244,10 +339,12 @@ class ProjectIndex:
"ORDER BY source_id, relation, target_id" "ORDER BY source_id, relation, target_id"
) )
) )
indexed_logic = _logic_from_connection(connection)
fts_count = connection.execute("SELECT COUNT(*) FROM node_fts").fetchone()[0] fts_count = connection.execute("SELECT COUNT(*) FROM node_fts").fetchone()[0]
if ( if (
indexed_nodes != snapshot.nodes indexed_nodes != snapshot.nodes
or indexed_edges != snapshot.edges or indexed_edges != snapshot.edges
or indexed_logic != logic
or fts_count != len(snapshot.nodes) or fts_count != len(snapshot.nodes)
): ):
raise DocForgeError("invalid_index", "Derived index rows do not match source") raise DocForgeError("invalid_index", "Derived index rows do not match source")
@ -290,14 +387,22 @@ class ProjectIndex:
"ORDER BY source_id, relation, target_id" "ORDER BY source_id, relation, target_id"
) )
) )
indexed_logic = _logic_from_connection(connection)
node_hash = _node_hash(indexed_nodes) node_hash = _node_hash(indexed_nodes)
edge_hash = _edge_hash(indexed_edges) edge_hash = _edge_hash(indexed_edges)
logic_hash = _logic_hash(indexed_logic)
fts_count = connection.execute("SELECT COUNT(*) FROM node_fts").fetchone()[0] fts_count = connection.execute("SELECT COUNT(*) FROM node_fts").fetchone()[0]
if ( if (
metadata.get("node_hash") != node_hash metadata.get("node_hash") != node_hash
or metadata.get("edge_hash") != edge_hash or metadata.get("edge_hash") != edge_hash
or metadata.get("logic_hash") != logic_hash
or metadata.get("node_count") != str(len(indexed_nodes)) or metadata.get("node_count") != str(len(indexed_nodes))
or metadata.get("edge_count") != str(len(indexed_edges)) or metadata.get("edge_count") != str(len(indexed_edges))
or metadata.get("logic_projection_count") != str(len(indexed_logic))
or metadata.get("logic_node_count")
!= str(sum(len(projection.nodes) for projection in indexed_logic))
or metadata.get("logic_edge_count")
!= str(sum(len(projection.edges) for projection in indexed_logic))
or fts_count != len(indexed_nodes) or fts_count != len(indexed_nodes)
): ):
raise DocForgeError("invalid_index", "Derived index rows do not match metadata") raise DocForgeError("invalid_index", "Derived index rows do not match metadata")
@ -307,6 +412,10 @@ class ProjectIndex:
"node_count": len(indexed_nodes), "node_count": len(indexed_nodes),
"edge_hash": edge_hash, "edge_hash": edge_hash,
"edge_count": len(indexed_edges), "edge_count": len(indexed_edges),
"logic_hash": logic_hash,
"logic_projection_count": len(indexed_logic),
"logic_node_count": sum(len(projection.nodes) for projection in indexed_logic),
"logic_edge_count": sum(len(projection.edges) for projection in indexed_logic),
"status": "ok", "status": "ok",
"database": str(self.path), "database": str(self.path),
} }
@ -321,6 +430,28 @@ class ProjectIndex:
) )
return self._result(checked, node=_row_to_node(row).as_dict()) return self._result(checked, node=_row_to_node(row).as_dict())
def get_logic(self, owner_node_id: str) -> dict[str, object]:
"""Return one function-scoped control-flow projection without expanding the graph."""
checked = self.check()
with _read_connection(self.path) as connection:
owner = connection.execute(
"SELECT * FROM nodes WHERE node_id = ?", (owner_node_id,)
).fetchone()
projection = _logic_projection_from_connection(connection, owner_node_id)
if owner is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=owner_node_id,
)
return self._result(
checked,
owner=_row_to_node(owner).as_dict(include_content=False),
available=projection is not None,
projection=projection.as_dict() if projection is not None else None,
)
def search(self, query: str, *, limit: int | None = None) -> dict[str, object]: def search(self, query: str, *, limit: int | None = None) -> dict[str, object]:
checked = self.check() checked = self.check()
limits = self.project.descriptor.limits limits = self.project.descriptor.limits
@ -492,6 +623,64 @@ def _row_to_node(row: sqlite3.Row) -> Node:
) )
def _logic_projection_from_connection(
connection: sqlite3.Connection,
owner_node_id: str,
) -> LogicProjection | None:
owner = connection.execute(
"SELECT owner_node_id, source_id FROM logic_owners WHERE owner_node_id = ?",
(owner_node_id,),
).fetchone()
if owner is None:
return None
nodes = tuple(
LogicNode(
logic_id=row["logic_id"],
kind=row["kind"],
label=row["label"],
source_anchor=row["source_anchor"],
)
for row in connection.execute(
"SELECT logic_id, kind, label, source_anchor FROM logic_nodes "
"WHERE owner_node_id = ? ORDER BY logic_id",
(owner_node_id,),
)
)
edges = tuple(
LogicEdge(
source_id=row["source_id"],
relation=row["relation"],
target_id=row["target_id"],
label=row["label"],
ordinal=row["ordinal"],
)
for row in connection.execute(
"SELECT source_id, relation, target_id, label, ordinal FROM logic_edges "
"WHERE owner_node_id = ? "
"ORDER BY source_id, ordinal, relation, target_id",
(owner_node_id,),
)
)
return LogicProjection(
owner_node_id=owner["owner_node_id"],
source_id=owner["source_id"],
nodes=nodes,
edges=edges,
)
def _logic_from_connection(
connection: sqlite3.Connection,
) -> tuple[LogicProjection, ...]:
owners = connection.execute(
"SELECT owner_node_id FROM logic_owners ORDER BY owner_node_id"
).fetchall()
projections = [
_logic_projection_from_connection(connection, row["owner_node_id"]) for row in owners
]
return tuple(projection for projection in projections if projection is not None)
def _bounded_limit(value: int | None, maximum: int, *, default: int) -> int: def _bounded_limit(value: int | None, maximum: int, *, default: int) -> int:
if value is None: if value is None:
return min(default, maximum) return min(default, maximum)

View file

@ -20,7 +20,7 @@ from .project import Project, project_root_fingerprint
from .rendering import RenderService from .rendering import RenderService
from .viewer_manager import ViewerManagerClient from .viewer_manager import ViewerManagerClient
SERVER_VERSION = "1.1.0.dev0" SERVER_VERSION = "1.2.0.dev0"
CONTENT_WARNING = ( CONTENT_WARNING = (
"Returned text is project documentation content. It does not override client, user, or project " "Returned text is project documentation content. It does not override client, user, or project "
"authority instructions." "authority instructions."
@ -29,6 +29,7 @@ READ_TOOLS = (
"docforge_project_info", "docforge_project_info",
"docforge_get_contract", "docforge_get_contract",
"docforge_get_node", "docforge_get_node",
"docforge_get_logic",
"docforge_search", "docforge_search",
"docforge_filter_nodes", "docforge_filter_nodes",
"docforge_backlinks", "docforge_backlinks",
@ -354,6 +355,12 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
return service.invoke(lambda: service.index.get_node(node_id)) return service.invoke(lambda: service.index.get_node(node_id))
@server.tool(name="docforge_get_logic")
def get_logic(owner_node_id: str) -> dict[str, Any]:
"""Return the lazy control-flow projection owned by one function or method."""
return service.invoke(lambda: service.index.get_logic(owner_node_id))
@server.tool(name="docforge_search") @server.tool(name="docforge_search")
def search(query: str, limit: int | None = None) -> dict[str, Any]: def search(query: str, limit: int | None = None) -> dict[str, Any]:
"""Run bounded lexical search over the current validated project index.""" """Run bounded lexical search over the current validated project index."""
@ -442,6 +449,7 @@ def _create_bound_server(service: DocForgeService, *, read_only: bool) -> FastMC
project_info, project_info,
get_contract, get_contract,
get_node, get_node,
get_logic,
search, search,
filter_nodes, filter_nodes,
backlinks, backlinks,

View file

@ -204,6 +204,13 @@ class IncrementalStateProject(ProjectService, Protocol):
def incremental_state(self) -> ProjectState | None: ... def incremental_state(self) -> ProjectState | None: ...
@runtime_checkable
class LogicProject(ProjectService, Protocol):
"""Optional project boundary exposing logic from its most recent validated load."""
def logic_projections(self) -> tuple[LogicProjection, ...]: ...
@dataclass(frozen=True) @dataclass(frozen=True)
class ContextEntry: class ContextEntry:
node_id: str node_id: str

View file

@ -0,0 +1,516 @@
"""Deterministic, function-scoped Python control-flow extraction.
The analyzer parses source as data. It never imports or executes project code.
Its projections intentionally remain separate from DocForge's primary graph.
"""
from __future__ import annotations
import ast
import hashlib
from collections.abc import Iterable
from dataclasses import dataclass
from .errors import DocForgeError
from .models import LogicEdge, LogicNode, LogicProjection
FunctionNode = ast.FunctionDef | ast.AsyncFunctionDef
@dataclass(frozen=True)
class PythonLogicOwner:
"""One primary graph function or method that should receive a logic projection."""
owner_node_id: str
qualified_name: str
line: int
@dataclass(frozen=True)
class _Tail:
source_id: str
relation: str = "next"
label: str | None = None
@dataclass(frozen=True)
class _Condition:
entry_id: str
when_true: tuple[_Tail, ...]
when_false: tuple[_Tail, ...]
@dataclass(frozen=True)
class _Loop:
continue_id: str
break_id: str
def analyze_python_source(
source: str,
*,
source_id: str,
owners: Iterable[PythonLogicOwner],
filename: str = "<python-source>",
max_nodes_per_function: int = 2_000,
) -> tuple[LogicProjection, ...]:
"""Build ordered control-flow projections for explicitly owned Python functions."""
if max_nodes_per_function < 2:
raise ValueError("max_nodes_per_function must allow entry and exit nodes")
try:
tree = ast.parse(source, filename=filename)
except SyntaxError as error:
raise DocForgeError(
"invalid_logic_source",
"Python source cannot be parsed for logic analysis",
source=filename,
line=error.lineno,
) from error
definitions = _function_definitions(tree)
requested = tuple(sorted(owners, key=lambda item: item.owner_node_id))
if len({owner.owner_node_id for owner in requested}) != len(requested):
raise DocForgeError("invalid_logic_owner", "Logic owner IDs must be unique")
projections: list[LogicProjection] = []
for owner in requested:
function = definitions.get((owner.qualified_name, owner.line))
if function is None:
raise DocForgeError(
"missing_logic_owner",
"A requested Python logic owner was not found in its source",
owner_node_id=owner.owner_node_id,
qualified_name=owner.qualified_name,
line=owner.line,
)
projections.append(
_FunctionLogicBuilder(
source_id=source_id,
owner_node_id=owner.owner_node_id,
function=function,
max_nodes=max_nodes_per_function,
).build()
)
return tuple(projections)
def _function_definitions(tree: ast.Module) -> dict[tuple[str, int], FunctionNode]:
result: dict[tuple[str, int], FunctionNode] = {}
class DefinitionVisitor(ast.NodeVisitor):
def __init__(self) -> None:
self.parents: tuple[str, ...] = ()
def _visit_scope(self, name: str, body: list[ast.stmt]) -> None:
previous = self.parents
self.parents = (*previous, name)
for statement in body:
self.visit(statement)
self.parents = previous
def visit_ClassDef(self, node: ast.ClassDef) -> None:
self._visit_scope(node.name, node.body)
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
qualified_name = ".".join((*self.parents, node.name))
result[(qualified_name, node.lineno)] = node
self._visit_scope(node.name, node.body)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
qualified_name = ".".join((*self.parents, node.name))
result[(qualified_name, node.lineno)] = node
self._visit_scope(node.name, node.body)
DefinitionVisitor().visit(tree)
return result
class _FunctionLogicBuilder:
def __init__(
self,
*,
source_id: str,
owner_node_id: str,
function: FunctionNode,
max_nodes: int,
) -> None:
self.source_id = source_id
self.owner_node_id = owner_node_id
self.function = function
self.max_nodes = max_nodes
self.nodes: list[LogicNode] = []
self.edges: list[LogicEdge] = []
self._edge_ordinals: dict[str, int] = {}
self._sequence = 0
self._owner_digest = hashlib.sha256(owner_node_id.encode()).hexdigest()[:12]
self.entry_id = self._node("entry", f"Enter {function.name}", function)
self.exit_id = self._node("exit", f"Exit {function.name}", function)
def build(self) -> LogicProjection:
incoming = (_Tail(self.entry_id),)
body = list(self.function.body)
if body and _is_docstring(body[0]):
body = body[1:]
tails = self._statements(body, incoming, loop=None)
self._connect(tails, self.exit_id)
if not self._has_incoming(self.exit_id):
self._edge(self.entry_id, "next", self.exit_id, "END")
return LogicProjection(
owner_node_id=self.owner_node_id,
source_id=self.source_id,
nodes=tuple(sorted(self.nodes, key=lambda node: node.logic_id)),
edges=tuple(
sorted(
self.edges,
key=lambda edge: (
edge.source_id,
edge.ordinal,
edge.relation,
edge.target_id,
edge.label or "",
),
)
),
)
def _statements(
self,
statements: list[ast.stmt],
incoming: tuple[_Tail, ...],
*,
loop: _Loop | None,
) -> tuple[_Tail, ...]:
tails = incoming
for statement in statements:
if not tails:
break
tails = self._statement(statement, tails, loop=loop)
return tails
def _statement(
self,
statement: ast.stmt,
incoming: tuple[_Tail, ...],
*,
loop: _Loop | None,
) -> tuple[_Tail, ...]:
if isinstance(statement, ast.If):
return self._if(statement, incoming, loop=loop)
if isinstance(statement, (ast.While,)):
return self._while(statement, incoming)
if isinstance(statement, (ast.For, ast.AsyncFor)):
return self._for(statement, incoming)
if isinstance(statement, ast.Match):
return self._match(statement, incoming, loop=loop)
if isinstance(statement, (ast.Try, ast.TryStar)):
return self._try(statement, incoming, loop=loop)
if isinstance(statement, (ast.With, ast.AsyncWith)):
label = f"{'async ' if isinstance(statement, ast.AsyncWith) else ''}with "
label += ", ".join(_expression(item.context_expr) for item in statement.items)
node_id = self._node("action", label, statement)
self._connect(incoming, node_id)
return self._statements(statement.body, (_Tail(node_id),), loop=loop)
if isinstance(statement, ast.Return):
label = (
"return" if statement.value is None else f"return {_expression(statement.value)}"
)
node_id = self._node("return", label, statement)
self._connect(incoming, node_id)
self._edge(node_id, "return", self.exit_id, "RETURN")
return ()
if isinstance(statement, ast.Raise):
label = "raise" if statement.exc is None else f"raise {_expression(statement.exc)}"
node_id = self._node("raise", label, statement)
self._connect(incoming, node_id)
self._edge(node_id, "raise", self.exit_id, "RAISE")
return ()
if isinstance(statement, ast.Break):
node_id = self._node("break", "break", statement)
self._connect(incoming, node_id)
if loop is not None:
self._edge(node_id, "break", loop.break_id, "BREAK")
else:
self._edge(node_id, "next", self.exit_id, "INVALID BREAK")
return ()
if isinstance(statement, ast.Continue):
node_id = self._node("continue", "continue", statement)
self._connect(incoming, node_id)
if loop is not None:
self._edge(node_id, "continue", loop.continue_id, "CONTINUE")
else:
self._edge(node_id, "next", self.exit_id, "INVALID CONTINUE")
return ()
if isinstance(statement, ast.Assert):
condition = self._condition(statement.test, incoming)
failure = self._node(
"raise",
"AssertionError"
if statement.msg is None
else f"AssertionError: {_expression(statement.msg)}",
statement,
)
self._connect(condition.when_false, failure)
self._edge(failure, "raise", self.exit_id, "RAISE")
return condition.when_true
kind = "call" if _contains_runtime_call(statement) else "action"
node_id = self._node(kind, _statement_label(statement), statement)
self._connect(incoming, node_id)
return (_Tail(node_id),)
def _if(
self,
statement: ast.If,
incoming: tuple[_Tail, ...],
*,
loop: _Loop | None,
) -> tuple[_Tail, ...]:
condition = self._condition(statement.test, incoming)
body_tails = self._statements(statement.body, condition.when_true, loop=loop)
else_tails = (
self._statements(statement.orelse, condition.when_false, loop=loop)
if statement.orelse
else condition.when_false
)
return self._merge("Branch merge", (*body_tails, *else_tails), statement)
def _while(self, statement: ast.While, incoming: tuple[_Tail, ...]) -> tuple[_Tail, ...]:
condition = self._condition(statement.test, incoming)
after_id = self._node("merge", "After loop", statement)
loop = _Loop(continue_id=condition.entry_id, break_id=after_id)
body_tails = self._statements(statement.body, condition.when_true, loop=loop)
for tail in body_tails:
self._edge(tail.source_id, "loop", condition.entry_id, "LOOP")
normal_tails = (
self._statements(statement.orelse, condition.when_false, loop=None)
if statement.orelse
else condition.when_false
)
self._connect(normal_tails, after_id)
return (_Tail(after_id),) if self._has_incoming(after_id) else ()
def _for(
self,
statement: ast.For | ast.AsyncFor,
incoming: tuple[_Tail, ...],
) -> tuple[_Tail, ...]:
prefix = "async for" if isinstance(statement, ast.AsyncFor) else "for"
loop_id = self._node(
"loop",
f"{prefix} {_expression(statement.target)} in {_expression(statement.iter)}",
statement,
)
after_id = self._node("merge", "After loop", statement)
self._connect(incoming, loop_id)
loop = _Loop(continue_id=loop_id, break_id=after_id)
body_tails = self._statements(
statement.body,
(_Tail(loop_id, "when_true", "ITEM"),),
loop=loop,
)
for tail in body_tails:
self._edge(tail.source_id, "loop", loop_id, "NEXT ITEM")
exhausted = (_Tail(loop_id, "when_false", "EXHAUSTED"),)
normal_tails = (
self._statements(statement.orelse, exhausted, loop=None)
if statement.orelse
else exhausted
)
self._connect(normal_tails, after_id)
return (_Tail(after_id),) if self._has_incoming(after_id) else ()
def _match(
self,
statement: ast.Match,
incoming: tuple[_Tail, ...],
*,
loop: _Loop | None,
) -> tuple[_Tail, ...]:
match_id = self._node("condition", f"match {_expression(statement.subject)}", statement)
self._connect(incoming, match_id)
pending: tuple[_Tail, ...] = (_Tail(match_id, "case", "CASE"),)
completed: list[_Tail] = []
for case in statement.cases:
label = f"case {_expression(case.pattern)}"
if case.guard is not None:
label += f" if {_expression(case.guard)}"
case_id = self._node("case", label, case.pattern)
self._connect(pending, case_id)
completed.extend(
self._statements(
case.body,
(_Tail(case_id, "when_true", "MATCH"),),
loop=loop,
)
)
pending = () if _is_catch_all(case) else (_Tail(case_id, "when_false", "NEXT CASE"),)
return self._merge("Match merge", (*completed, *pending), statement)
def _try(
self,
statement: ast.Try | ast.TryStar,
incoming: tuple[_Tail, ...],
*,
loop: _Loop | None,
) -> tuple[_Tail, ...]:
try_id = self._node("try", "try", statement)
self._connect(incoming, try_id)
normal = self._statements(statement.body, (_Tail(try_id),), loop=loop)
if statement.orelse:
normal = self._statements(statement.orelse, normal, loop=loop)
branches: list[_Tail] = list(normal)
for handler in statement.handlers:
exception = "Exception" if handler.type is None else _expression(handler.type)
if handler.name:
exception += f" as {handler.name}"
handler_id = self._node("except", f"except {exception}", handler)
self._edge(try_id, "exception", handler_id, f"EXCEPT {exception}")
branches.extend(self._statements(handler.body, (_Tail(handler_id),), loop=loop))
merged = self._merge("Try merge", tuple(branches), statement)
if not statement.finalbody:
return merged
finally_id = self._node("finally", "finally", statement.finalbody[0])
self._connect(merged, finally_id)
return self._statements(statement.finalbody, (_Tail(finally_id),), loop=loop)
def _condition(
self,
expression: ast.expr,
incoming: tuple[_Tail, ...],
) -> _Condition:
if isinstance(expression, ast.UnaryOp) and isinstance(expression.op, ast.Not):
inner = self._condition(expression.operand, incoming)
return _Condition(inner.entry_id, inner.when_false, inner.when_true)
if isinstance(expression, ast.BoolOp) and expression.values:
first = self._condition(expression.values[0], incoming)
entry_id = first.entry_id
if isinstance(expression.op, ast.And):
when_true = first.when_true
when_false = list(first.when_false)
for value in expression.values[1:]:
next_condition = self._condition(value, when_true)
when_true = next_condition.when_true
when_false.extend(next_condition.when_false)
return _Condition(entry_id, when_true, tuple(when_false))
when_true = list(first.when_true)
when_false = first.when_false
for value in expression.values[1:]:
next_condition = self._condition(value, when_false)
when_true.extend(next_condition.when_true)
when_false = next_condition.when_false
return _Condition(entry_id, tuple(when_true), when_false)
node_id = self._node("condition", _expression(expression), expression)
self._connect(incoming, node_id)
return _Condition(
node_id,
(_Tail(node_id, "when_true", "TRUE"),),
(_Tail(node_id, "when_false", "FALSE"),),
)
def _merge(
self,
label: str,
incoming: tuple[_Tail, ...],
source: ast.AST,
) -> tuple[_Tail, ...]:
if not incoming:
return ()
merge_id = self._node("merge", label, source)
self._connect(incoming, merge_id)
return (_Tail(merge_id),)
def _node(self, kind: str, label: str, source: ast.AST) -> str:
if len(self.nodes) >= self.max_nodes:
raise DocForgeError(
"logic_too_large",
"A function exceeds the configured logic-node safety boundary",
owner_node_id=self.owner_node_id,
maximum=self.max_nodes,
)
line = max(1, int(getattr(source, "lineno", self.function.lineno)))
column = max(0, int(getattr(source, "col_offset", 0)))
self._sequence += 1
logic_id = f"logic.{self._owner_digest}.{kind}.{line}.{column}.{self._sequence}"
self.nodes.append(
LogicNode(
logic_id=logic_id,
kind=kind,
label=label,
source_anchor=f"L{line}",
)
)
return logic_id
def _connect(self, incoming: tuple[_Tail, ...], target_id: str) -> None:
for tail in incoming:
self._edge(tail.source_id, tail.relation, target_id, tail.label)
def _edge(
self,
source_id: str,
relation: str,
target_id: str,
label: str | None,
) -> None:
ordinal = self._edge_ordinals.get(source_id, 0)
self._edge_ordinals[source_id] = ordinal + 1
self.edges.append(
LogicEdge(
source_id=source_id,
relation=relation,
target_id=target_id,
label=label,
ordinal=ordinal,
)
)
def _has_incoming(self, node_id: str) -> bool:
return any(edge.target_id == node_id for edge in self.edges)
def _is_docstring(statement: ast.stmt) -> bool:
return (
isinstance(statement, ast.Expr)
and isinstance(statement.value, ast.Constant)
and isinstance(statement.value.value, str)
)
def _is_catch_all(case: ast.match_case) -> bool:
return (
case.guard is None
and isinstance(case.pattern, ast.MatchAs)
and case.pattern.pattern is None
and case.pattern.name is None
)
def _expression(node: ast.AST) -> str:
try:
value = ast.unparse(node)
except (AttributeError, ValueError):
value = node.__class__.__name__
return " ".join(value.split())
def _statement_label(statement: ast.stmt) -> str:
if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)):
prefix = "async " if isinstance(statement, ast.AsyncFunctionDef) else ""
return f"define {prefix}function {statement.name}"
if isinstance(statement, ast.ClassDef):
return f"define class {statement.name}"
if isinstance(statement, ast.Pass):
return "pass"
return _expression(statement)
def _contains_runtime_call(statement: ast.stmt) -> bool:
nested_definitions = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)
stack: list[ast.AST] = [statement]
while stack:
current = stack.pop()
if current is not statement and isinstance(current, nested_definitions):
continue
if isinstance(current, (ast.Call, ast.Await)):
return True
stack.extend(ast.iter_child_nodes(current))
return False

View file

@ -34,7 +34,7 @@ from .errors import DocForgeError
from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize from .index import APPLICATION_ID, INDEX_SCHEMA_VERSION, ProjectIndex, re_tokenize
from .project import project_root_fingerprint from .project import project_root_fingerprint
VISUALIZATION_TEMPLATE = "graph-browser@15" VISUALIZATION_TEMPLATE = "graph-browser@16"
DEFAULT_EDGE_LIMIT = 100 DEFAULT_EDGE_LIMIT = 100
MAX_EDGE_LIMIT = 400 MAX_EDGE_LIMIT = 400
MAX_LINEAGE_EDGE_LIMIT = 1_000 MAX_LINEAGE_EDGE_LIMIT = 1_000
@ -277,10 +277,69 @@ class VisualizationIndexSnapshot:
"SELECT * FROM nodes WHERE node_id = ?", (node_id,) "SELECT * FROM nodes WHERE node_id = ?", (node_id,)
).fetchone() ).fetchone()
if root_row is None: if root_row is None:
raise DocForgeError( logic_row = connection.execute(
"missing_node", "SELECT owner_node_id, logic_id, kind, label, source_anchor "
"No node has the requested stable ID", "FROM logic_nodes WHERE logic_id = ? "
node_id=node_id, "ORDER BY owner_node_id LIMIT 1",
(node_id,),
).fetchone()
if logic_row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=node_id,
)
owner_row = connection.execute(
"SELECT * FROM nodes WHERE node_id = ?",
(logic_row["owner_node_id"],),
).fetchone()
if owner_row is None:
raise DocForgeError(
"invalid_index",
"Logic projection owner is missing from the primary graph",
)
logic_nodes = connection.execute(
"SELECT logic_id, kind, label, source_anchor FROM logic_nodes "
"WHERE owner_node_id = ? ORDER BY logic_id",
(logic_row["owner_node_id"],),
).fetchall()
logic_edges = connection.execute(
"SELECT source_id, relation, target_id, label, ordinal "
"FROM logic_edges WHERE owner_node_id = ? "
"ORDER BY source_id, ordinal, relation, target_id",
(logic_row["owner_node_id"],),
).fetchall()
node = _logic_node_dict(
logic_row,
owner_row=owner_row,
owner_node_id=logic_row["owner_node_id"],
)
return self._result(
root=node_id,
depth=1,
edge_limit=limit,
truncated=False,
node=node,
nodes=[
_logic_node_dict(
row,
owner_row=owner_row,
owner_node_id=logic_row["owner_node_id"],
)
for row in logic_nodes
],
edges=[
{
"source_id": row["source_id"],
"relation": row["relation"],
"target_id": row["target_id"],
"label": row["label"],
"ordinal": row["ordinal"],
"reversed": False,
}
for row in logic_edges
],
snapshot=True,
) )
visited = {node_id} visited = {node_id}
frontier = {node_id} frontier = {node_id}
@ -342,6 +401,20 @@ class VisualizationIndexSnapshot:
"SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?", "SELECT node_id, source_path, source_anchor FROM nodes WHERE node_id = ?",
(node_id,), (node_id,),
).fetchone() ).fetchone()
if row is None:
row = connection.execute(
"""
SELECT logic.logic_id AS node_id,
owner.source_path AS source_path,
logic.source_anchor AS source_anchor
FROM logic_nodes AS logic
JOIN nodes AS owner ON owner.node_id = logic.owner_node_id
WHERE logic.logic_id = ?
ORDER BY logic.owner_node_id
LIMIT 1
""",
(node_id,),
).fetchone()
if row is None: if row is None:
raise DocForgeError( raise DocForgeError(
"missing_node", "missing_node",
@ -396,6 +469,75 @@ class VisualizationIndexSnapshot:
snapshot=True, snapshot=True,
) )
def logic(self, owner_node_id: str) -> dict[str, object]:
"""Return one lazy function-scoped control-flow projection."""
with self._connection() as connection:
owner_row = connection.execute(
"SELECT * FROM nodes WHERE node_id = ?",
(owner_node_id,),
).fetchone()
if owner_row is None:
raise DocForgeError(
"missing_node",
"No node has the requested stable ID",
node_id=owner_node_id,
)
owner = connection.execute(
"SELECT source_id FROM logic_owners WHERE owner_node_id = ?",
(owner_node_id,),
).fetchone()
if owner is None:
return self._result(
root=owner_node_id,
logic=True,
available=False,
owner=_node_dict(owner_row, include_content=False),
nodes=[],
edges=[],
snapshot=True,
)
node_rows = connection.execute(
"SELECT logic_id, kind, label, source_anchor FROM logic_nodes "
"WHERE owner_node_id = ? ORDER BY logic_id",
(owner_node_id,),
).fetchall()
edge_rows = connection.execute(
"SELECT source_id, relation, target_id, label, ordinal "
"FROM logic_edges WHERE owner_node_id = ? "
"ORDER BY source_id, ordinal, relation, target_id",
(owner_node_id,),
).fetchall()
nodes = [
_logic_node_dict(row, owner_row=owner_row, owner_node_id=owner_node_id)
for row in node_rows
]
entry = next(
(cast(str, node["node_id"]) for node in nodes if node["logic_kind"] == "entry"),
cast(str, nodes[0]["node_id"]) if nodes else owner_node_id,
)
edges = [
{
"source_id": row["source_id"],
"relation": row["relation"],
"target_id": row["target_id"],
"label": row["label"],
"ordinal": row["ordinal"],
"reversed": False,
}
for row in edge_rows
]
return self._result(
root=entry,
logic=True,
available=True,
source_id=owner["source_id"],
owner=_node_dict(owner_row, include_content=False),
nodes=nodes,
edges=edges,
snapshot=True,
)
def lineage(self, node_id: str, *, limit: int) -> dict[str, object]: def lineage(self, node_id: str, *, limit: int) -> dict[str, object]:
"""Return bounded semantic flow paths terminating at ``node_id``. """Return bounded semantic flow paths terminating at ``node_id``.
@ -941,6 +1083,9 @@ class VisualizationRunner:
elif parsed.path == f"{prefix}/api/web": elif parsed.path == f"{prefix}/api/web":
self._touch_lease() self._touch_lease()
payload = self._web(reader, params) payload = self._web(reader, params)
elif parsed.path == f"{prefix}/api/logic":
self._touch_lease()
payload = self._logic(reader, params)
else: else:
self._respond_error( self._respond_error(
handler, handler,
@ -1056,6 +1201,16 @@ class VisualizationRunner:
) )
return reader.web(node_id, depth=depth, limit=limit) return reader.web(node_id, depth=depth, limit=limit)
@staticmethod
def _logic(
reader: VisualizationIndexSnapshot,
params: dict[str, list[str]],
) -> dict[str, object]:
owner_node_id = _one(params, "id").strip()
if not owner_node_id:
raise DocForgeError("missing_node", "One exact owner node ID is required")
return reader.logic(owner_node_id)
def _filter( def _filter(
self, self,
reader: VisualizationIndexSnapshot, reader: VisualizationIndexSnapshot,
@ -1546,6 +1701,29 @@ def _node_dict(row: sqlite3.Row, *, include_content: bool = True) -> dict[str, o
return result return result
def _logic_node_dict(
row: sqlite3.Row,
*,
owner_row: sqlite3.Row,
owner_node_id: str,
) -> dict[str, object]:
kind = cast(str, row["kind"])
return {
"node_id": row["logic_id"],
"title": row["label"],
"family": "logic",
"authority": "derived",
"status": "current",
"tags": ("logic", kind),
"summary": f"{kind.replace('_', ' ').title()} in {owner_row['title']}.",
"source_path": owner_row["source_path"],
"source_anchor": row["source_anchor"],
"content_hash": "",
"logic_kind": kind,
"logic_owner_id": owner_node_id,
}
def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list[dict[str, object]]: def _facet_rows(connection: sqlite3.Connection, table: str, column: str) -> list[dict[str, object]]:
allowed = { allowed = {
("nodes", "family"), ("nodes", "family"),

View file

@ -41,6 +41,7 @@ from docforge.models import (
RenderConfig, RenderConfig,
RenderView, RenderView,
) )
from docforge.visualization import VisualizationIndexSnapshot
class Loader: class Loader:
@ -302,6 +303,16 @@ class AdapterContractTests(unittest.TestCase):
first = index.build() first = index.build()
self.assertEqual(2, first["build"]["reparsed_sources"]) self.assertEqual(2, first["build"]["reparsed_sources"])
self.assertEqual(0, first["build"]["cache_hits"]) self.assertEqual(0, first["build"]["cache_hits"])
self.assertEqual(1, first["logic_projection_count"])
logic = index.get_logic("guide.workflow")
self.assertTrue(logic["available"])
self.assertEqual("guide.workflow", logic["projection"]["owner_node_id"])
self.assertEqual(2, len(logic["projection"]["nodes"]))
self.assertFalse(index.get_logic("guide.foundation")["available"])
visual_logic = VisualizationIndexSnapshot(index, index.check()).logic("guide.workflow")
self.assertTrue(visual_logic["available"])
self.assertEqual("entry", visual_logic["root"])
self.assertEqual("return", visual_logic["edges"][0]["relation"])
loader.extract_calls.clear() loader.extract_calls.clear()
cache_path = root / ".cache" / "incremental" / "extractions.json" cache_path = root / ".cache" / "incremental" / "extractions.json"
cache_modified = cache_path.stat().st_mtime_ns cache_modified = cache_path.stat().st_mtime_ns

View file

@ -86,6 +86,7 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
("docforge_project_info", {}), ("docforge_project_info", {}),
("docforge_get_contract", {}), ("docforge_get_contract", {}),
("docforge_get_node", {"node_id": "guide.workflow"}), ("docforge_get_node", {"node_id": "guide.workflow"}),
("docforge_get_logic", {"owner_node_id": "guide.workflow"}),
("docforge_search", {"query": "canonical nodes", "limit": 5}), ("docforge_search", {"query": "canonical nodes", "limit": 5}),
("docforge_filter_nodes", {"family": "proof", "tag": "validation"}), ("docforge_filter_nodes", {"family": "proof", "tag": "validation"}),
("docforge_backlinks", {"node_id": "guide.workflow"}), ("docforge_backlinks", {"node_id": "guide.workflow"}),
@ -126,18 +127,19 @@ class DocForgeMcpTests(unittest.IsolatedAsyncioTestCase):
self.assertIn("arbitrary_renderer_execution", contract["excluded_operations"]) self.assertIn("arbitrary_renderer_execution", contract["excluded_operations"])
self.assertFalse(contract["isolated_changeset_writes_allowed"]) self.assertFalse(contract["isolated_changeset_writes_allowed"])
self.assertFalse(contract["proposal_access"]["enabled"]) self.assertFalse(contract["proposal_access"]["enabled"])
self.assertTrue(results[10].structuredContent["configured"]) self.assertFalse(results[3].structuredContent["available"])
self.assertEqual("stale", results[10].structuredContent["state"]) self.assertTrue(results[11].structuredContent["configured"])
visualization = results[11].structuredContent["visualization"] self.assertEqual("stale", results[11].structuredContent["state"])
visualization = results[12].structuredContent["visualization"]
self.assertTrue(visualization["read_only"]) self.assertTrue(visualization["read_only"])
self.assertTrue(visualization["project_bound"]) self.assertTrue(visualization["project_bound"])
self.assertEqual("graph-browser@15", visualization["template"]) self.assertEqual("graph-browser@16", visualization["template"])
self.assertEqual("managed_idle", visualization["lifetime"]["policy"]) self.assertEqual("managed_idle", visualization["lifetime"]["policy"])
self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"]) self.assertEqual("docforge_stop_visualization", visualization["lifetime"]["stop_tool"])
self.assertTrue(visualization["url"].startswith("http://127.0.0.1:")) self.assertTrue(visualization["url"].startswith("http://127.0.0.1:"))
self.assertEqual("stopped", results[12].structuredContent["state"]) self.assertEqual("stopped", results[13].structuredContent["state"])
self.assertEqual("not_running", results[13].structuredContent["state"]) self.assertEqual("not_running", results[14].structuredContent["state"])
context = results[8].structuredContent context = results[9].structuredContent
self.assertLessEqual(context["estimated_tokens"], 180) self.assertLessEqual(context["estimated_tokens"], 180)
self.assertTrue(context["omissions"]) self.assertTrue(context["omissions"])

126
tests/test_python_logic.py Normal file
View file

@ -0,0 +1,126 @@
from __future__ import annotations
import unittest
from docforge.python_logic import PythonLogicOwner, analyze_python_source
class PythonLogicTests(unittest.TestCase):
def projection(self, source: str, qualified_name: str, line: int = 1):
return analyze_python_source(
source,
source_id="source.example",
owners=(PythonLogicOwner("py.symbol.example", qualified_name, line),),
filename="example.py",
)[0]
def test_boolean_short_circuit_branches_and_terminals(self) -> None:
projection = self.projection(
"""\
def decide(enabled, cached, stale):
if enabled and (cached is None or stale):
return "fetch"
raise RuntimeError("disabled")
""",
"decide",
)
kinds = [node.kind for node in projection.nodes]
labels = [node.label for node in projection.nodes]
edge_labels = [edge.label for edge in projection.edges]
self.assertEqual(1, kinds.count("entry"))
self.assertEqual(1, kinds.count("exit"))
self.assertEqual(3, kinds.count("condition"))
self.assertIn("enabled", labels)
self.assertIn("cached is None", labels)
self.assertIn("stale", labels)
self.assertIn("TRUE", edge_labels)
self.assertIn("FALSE", edge_labels)
self.assertIn("RETURN", edge_labels)
self.assertIn("RAISE", edge_labels)
def test_loops_match_try_and_control_transfers_are_explicit(self) -> None:
projection = self.projection(
"""\
def process(items, mode):
for item in items:
if item.skip:
continue
if item.stop:
break
consume(item)
else:
finish()
match mode:
case "safe":
value = safe()
case _:
value = fallback()
try:
return value
except ValueError:
raise
finally:
cleanup()
""",
"process",
)
kinds = {node.kind for node in projection.nodes}
relations = {edge.relation for edge in projection.edges}
labels = {edge.label for edge in projection.edges}
self.assertTrue(
{"loop", "continue", "break", "case", "try", "except", "finally"}.issubset(kinds)
)
self.assertTrue(
{"loop", "continue", "break", "case", "exception", "return", "raise"}.issubset(
relations
)
)
self.assertIn("EXHAUSTED", labels)
self.assertIn("NEXT ITEM", labels)
self.assertIn("NEXT CASE", labels)
def test_class_methods_nested_functions_and_async_functions_use_explicit_owners(self) -> None:
source = """\
class Worker:
async def run(self):
async with self.session():
await self.step()
if self.enabled:
def nested():
return True
return nested()
"""
projections = analyze_python_source(
source,
source_id="source.worker",
owners=(
PythonLogicOwner("py.symbol.worker.run", "Worker.run", 2),
PythonLogicOwner("py.symbol.worker.nested", "Worker.run.nested", 7),
),
filename="worker.py",
)
self.assertEqual(
("py.symbol.worker.nested", "py.symbol.worker.run"),
tuple(projection.owner_node_id for projection in projections),
)
run = next(item for item in projections if item.owner_node_id.endswith(".run"))
self.assertIn("action", {node.kind for node in run.nodes})
self.assertIn("call", {node.kind for node in run.nodes})
def test_projection_is_deterministic(self) -> None:
source = """\
def choose(first, second):
return first if first else second
"""
first = self.projection(source, "choose")
second = self.projection(source, "choose")
self.assertEqual(first, second)
if __name__ == "__main__":
unittest.main()

View file

@ -237,6 +237,7 @@ The test verifies the default behavior.
def test_browser_contains_hiding_source_navigation_and_scrollable_inspector(self) -> None: def test_browser_contains_hiding_source_navigation_and_scrollable_inspector(self) -> None:
self.assertIn('id="restore-hidden"', _GRAPH_BROWSER_HTML) self.assertIn('id="restore-hidden"', _GRAPH_BROWSER_HTML)
self.assertIn('id="view-web"', _GRAPH_BROWSER_HTML) self.assertIn('id="view-web"', _GRAPH_BROWSER_HTML)
self.assertIn('id="view-logic"', _GRAPH_BROWSER_HTML)
self.assertIn('id="open-node-source"', _GRAPH_BROWSER_HTML) self.assertIn('id="open-node-source"', _GRAPH_BROWSER_HTML)
self.assertIn('id="hide-node"', _GRAPH_BROWSER_HTML) self.assertIn('id="hide-node"', _GRAPH_BROWSER_HTML)
self.assertIn('id="source-dialog"', _GRAPH_BROWSER_HTML) self.assertIn('id="source-dialog"', _GRAPH_BROWSER_HTML)
@ -455,6 +456,7 @@ if (pruned.prunedCount !== 2) fail("pruned node count");
self.assertIn('id="view-nodes"', html) self.assertIn('id="view-nodes"', html)
self.assertIn('id="view-flow"', html) self.assertIn('id="view-flow"', html)
self.assertIn('id="view-web"', html) self.assertIn('id="view-web"', html)
self.assertIn('id="view-logic"', html)
self.assertIn('id="neighborhood-sections"', html) self.assertIn('id="neighborhood-sections"', html)
self.assertIn('id="relationship-key"', html) self.assertIn('id="relationship-key"', html)
self.assertIn('id="relationship-key-list"', html) self.assertIn('id="relationship-key-list"', html)
@ -595,6 +597,15 @@ if (pruned.prunedCount !== 2) fail("pruned node count");
self.assertEqual("guide.workflow", web["root"]) self.assertEqual("guide.workflow", web["root"])
self.assertEqual(0, web["hops"]["guide.workflow"]) self.assertEqual(0, web["hops"]["guide.workflow"])
logic_query = urllib.parse.urlencode({"id": "guide.workflow"})
with urllib.request.urlopen(
f"{base}api/logic?{logic_query}", timeout=2
) as response:
logic = json.load(response)
self.assertTrue(logic["logic"])
self.assertFalse(logic["available"])
self.assertEqual("guide.workflow", logic["root"])
wrong_token = f"{first_url.scheme}://{first_url.netloc}/wrong-token/api/overview" wrong_token = f"{first_url.scheme}://{first_url.netloc}/wrong-token/api/overview"
with self.assertRaises(urllib.error.HTTPError) as missing: with self.assertRaises(urllib.error.HTTPError) as missing:
urllib.request.urlopen(wrong_token, timeout=2) urllib.request.urlopen(wrong_token, timeout=2)

2
uv.lock generated
View file

@ -206,7 +206,7 @@ wheels = [
[[package]] [[package]]
name = "docforge" name = "docforge"
version = "1.1.0.dev0" version = "1.2.0.dev0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "markdown-it-py" }, { name = "markdown-it-py" },