Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ef42201
Add load-path review experience: history, graph overlays, and editor …
cursoragent Aug 15, 2026
494c7ba
Scope screenshot wait to the review brief confidence level.
cursoragent Aug 15, 2026
41a34b2
Keep review path isolate off the architecture graph.
cursoragent Aug 15, 2026
950dc6b
Load indexed workspaces without blocking on the full graph.
cursoragent Aug 15, 2026
f85f727
Keep the architecture brief on screen while the graph request is in f…
cursoragent Aug 15, 2026
24f0a73
Clarify what-if walks and restore the last git-range review.
cursoragent Aug 15, 2026
59232e6
Keep the index progress bar moving only forward.
cursoragent Aug 16, 2026
bb2b251
Stagger overlapping graph edges so parallel routes stay distinct.
cursoragent Aug 16, 2026
6a35dfc
Give each bent graph edge its own vertical in the column gap.
cursoragent Aug 16, 2026
9ac2fd2
Assert staggered architecture edges and keep column extra-gap modest.
cursoragent Aug 16, 2026
aa0bd42
Space overlapping graph verticals instead of packing every corridor e…
cursoragent Aug 16, 2026
3b52403
Keep every outgoing edge from a column on shared vertical tracks.
cursoragent Aug 16, 2026
6562178
Bend skip-column edges in the first column gap.
cursoragent Aug 16, 2026
4681163
Stop clamping skip-column bends onto the same vertical.
cursoragent Aug 16, 2026
13c8dbd
Fix dual-review findings before merge.
cursoragent Aug 16, 2026
fcdfada
Wait for attached graph edges when the review pane clips them.
cursoragent Aug 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ npm run dev # Vite, proxies /api to the Django-shaped FastAPI server

If you change `ui/` and only run `loadpath serve`, you are looking at a stale bundle until you `npm run build`.

## Editor gutter

`editors/vscode` is a local Cursor/VS Code extension that polls `/api/marks` and badges files on the current load path (`S` seed, `!` untested sink, `C` contract). Install from that folder; `loadpath serve` must be running.

## Desktop

```bash
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ It is a local CLI, a desktop UI, and an MCP server you can point Cursor at. Not

## App

`loadpath serve --port 7345` opens a local desktop-style UI: icon rail, labeled toolbar, merge-box confidence, and an inspectable impact graph. The same process hosts MCP at `/mcp` (OAuth). AI is used **only** for residual uncertainty the graph cannot close. Twenty-four themes live in Settings and `localStorage`. Last repo, git range, and SCM slug are remembered the same way. Copy the markdown brief, or post **one** PR comment (updated in place) from the Review tab. Keyboard: `1`–`5` switches tabs. Outside Settings and Pull requests, `⌘`/`Ctrl`+`Enter` runs a review.
`loadpath serve --port 7345` opens a local desktop-style UI: icon rail, labeled toolbar, merge-box confidence, and an inspectable impact graph. The same process hosts MCP at `/mcp` (OAuth). AI is used **only** for residual uncertainty the graph cannot close. Twenty-four themes live in Settings and `localStorage`. Last repo, git range, SCM slug, and the last review id are remembered the same way. Copy the markdown brief, save HTML, or post **one** PR comment (updated in place) from the Review tab. Keyboard: `1`–`5` switches tabs. `⌘`/`Ctrl`+`K` opens the command palette. `j`/`k` walks read-order. Outside Settings and Pull requests, `⌘`/`Ctrl`+`Enter` runs a review. Click a finding, sink, or inspector neighbor to select it on the graph; Open in editor uses Cursor / VS Code. Watch the working tree to re-walk on save. Architecture edits `loadpath.yml` in place.

### Empty review

Expand Down Expand Up @@ -205,7 +205,7 @@ loadpath index /tmp/acme-billing
loadpath serve --open
```

Then point the UI at `/tmp/acme-billing`, or pick it from the repo explorer. `loadpath serve` always boots the app; it does not take a repo path. Default range is `HEAD~1`…`HEAD`. Toggle **Include uncommitted** to walk the working tree. Click a node → **What if this changes** to walk sinks without a git range. The read-order list is a guided tour (prev/next highlights the file on the graph).
Then point the UI at `/tmp/acme-billing`, or pick it from the repo explorer. `loadpath serve` always boots the app; it does not take a repo path. Default range is `HEAD~1`…`HEAD`. Toggle **Include uncommitted** to walk the working tree. Click a node → **What if this changes** to walk sinks as if that node changed — no git range, and **Back to git range** restores the last real review. Isolate path to sinks only filters the current map. The read-order list is a guided tour (prev/next highlights the file on the graph).

## GitHub Action merge gate

Expand Down Expand Up @@ -247,7 +247,7 @@ MCP URL: `https://your-tunnel.example/mcp` (or `http://127.0.0.1:7345/mcp` on th

**Cursor / Claude / ChatGPT / Gemini (HTTP + OAuth)** — add that MCP URL in the host’s connectors. The first connect opens a consent page on the Loadpath machine.

Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `list_remote_repositories`, `post_review_comment`, `what_if`, `review_pull_request`. `review` returns the load-path brief (confidence, sinks, reviewers, contract-break, auth, suggested tests, trend) — not hunk comments. `review_pull_request` fetches GitHub / GitLab / Bitbucket refs into a local clone first.
Tools: `list_workspaces`, `init_repo`, `index_repo`, `architecture`, `review`, `detect_repo`, `list_pull_requests`, `list_remote_repositories`, `post_review_comment`, `what_if`, `review_pull_request`, `load_path_marks`, `list_reviews`, `save_config`. `review` returns the load-path brief (confidence, sinks, reviewers, contract-break, auth, suggested tests, trend, checklist) — not hunk comments. `load_path_marks` is the gutter feed for the Cursor/VS Code extension in [`editors/vscode`](editors/vscode). `review_pull_request` fetches GitHub / GitLab / Bitbucket refs into a local clone first.

Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example) and [`fixtures/demo_monorepo/loadpath.yml`](fixtures/demo_monorepo/loadpath.yml)). The tool is opinionated about *your* architecture, not a generic module graph.

Expand Down
4 changes: 2 additions & 2 deletions desktop/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import path from "node:path";
import { fileURLToPath } from "node:url";

import { backendCommand, pickFreePort, waitForHealth } from "./backend.mjs";
import { isAllowedExternalUrl, isAppOrigin } from "./urls.mjs";
import { isAllowedExternalUrl, isAppOrigin, isEditorUrl } from "./urls.mjs";

const __dirname = path.dirname(fileURLToPath(import.meta.url));

Expand All @@ -19,7 +19,7 @@ function repoRoot() {
}

function openExternalIfAllowed(url) {
if (!isAllowedExternalUrl(url)) return;
if (!isAllowedExternalUrl(url) && !isEditorUrl(url)) return;
shell.openExternal(url).catch((err) => {
dialog.showErrorBox("Loadpath", `Could not open link: ${err.message}`);
});
Expand Down
11 changes: 11 additions & 0 deletions desktop/urls.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
export function isEditorUrl(url) {
let parsed;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.username || parsed.password) return false;
return parsed.protocol === "vscode:" || parsed.protocol === "cursor:" || parsed.protocol === "vscode-insiders:";
}

export function isAllowedExternalUrl(url) {
let parsed;
try {
Expand Down
11 changes: 10 additions & 1 deletion desktop/urls.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { isAllowedExternalUrl, isAppOrigin } from "./urls.mjs";
import { isAllowedExternalUrl, isAppOrigin, isEditorUrl } from "./urls.mjs";

describe("isAllowedExternalUrl", () => {
it("allows GitHub, GitLab, and Bitbucket https PR links", () => {
Expand All @@ -24,6 +24,15 @@ describe("isAllowedExternalUrl", () => {
});
});

describe("isEditorUrl", () => {
it("allows vscode and cursor file URLs without credentials", () => {
assert.equal(isEditorUrl("vscode://file/tmp/acme/a.py:12"), true);
assert.equal(isEditorUrl("cursor://file/tmp/acme/a.py"), true);
assert.equal(isEditorUrl("vscode://user:pass@file/tmp"), false);
assert.equal(isEditorUrl("https://github.com"), false);
});
});

describe("isAppOrigin", () => {
it("allows only the loopback backend origin", () => {
assert.equal(isAppOrigin("http://127.0.0.1:7345/api/health", 7345), true);
Expand Down
16 changes: 16 additions & 0 deletions editors/vscode/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Loadpath editor gutter

Marks files on the current Loadpath walk while `loadpath serve` is running on this machine.

```
code --install-extension /path/to/PR-Reviewer/editors/vscode
```

Or in Cursor: **Install from Location** → `editors/vscode`.

Settings:

- `loadpath.url` — default `http://127.0.0.1:7345`
- `loadpath.repoPath` — defaults to the workspace folder

Badges: `S` seed (the diff), `!` untested sink, `C` contract, `✓` tested, `→` downstream.
72 changes: 72 additions & 0 deletions editors/vscode/extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
const vscode = require("vscode");

function badgeColor(roles) {
if (roles.includes("untested")) return new vscode.ThemeColor("list.warningForeground");
if (roles.includes("seed")) return new vscode.ThemeColor("list.highlightForeground");
if (roles.includes("tested")) return new vscode.ThemeColor("testing.iconPassed");
return new vscode.ThemeColor("foreground");
}

class LoadpathDecorations {
constructor() {
this._onDidChange = new vscode.EventEmitter();
this.onDidChangeFileDecorations = this._onDidChange.event;
this.byPath = new Map();
}

provideFileDecoration(uri) {
const item = this.byPath.get(uri.fsPath) || this.byPath.get(uri.path);
if (!item) return;
return {
badge: item.badge || "·",
tooltip: item.tooltip || "On the Loadpath walk",
color: badgeColor(item.roles || []),
propagate: true,
};
}

replace(files, folder) {
this.byPath = new Map();
for (const file of files || []) {
const abs = folder ? require("path").join(folder, file.path) : file.path;
this.byPath.set(abs, file);
this.byPath.set(file.path, file);
}
this._onDidChange.fire(undefined);
}
}

async function fetchMarks(url, repoPath) {
const endpoint = `${url.replace(/\/$/, "")}/api/marks?repo_path=${encodeURIComponent(repoPath)}`;
const res = await fetch(endpoint);
if (!res.ok) throw new Error(`Loadpath marks ${res.status}`);
return res.json();
}

function activate(context) {
const decorations = new LoadpathDecorations();
context.subscriptions.push(vscode.window.registerFileDecorationProvider(decorations));

const refresh = async () => {
const folder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
const cfg = vscode.workspace.getConfiguration("loadpath");
const url = cfg.get("url") || "http://127.0.0.1:7345";
const repoPath = cfg.get("repoPath") || folder;
if (!repoPath) return;
try {
const payload = await fetchMarks(url, repoPath);
decorations.replace(payload.files || [], folder || repoPath);
} catch {
decorations.replace([], folder);
}
};

context.subscriptions.push(vscode.commands.registerCommand("loadpath.refreshMarks", refresh));
const timer = setInterval(refresh, 4000);
context.subscriptions.push({ dispose: () => clearInterval(timer) });
void refresh();
}

function deactivate() {}

module.exports = { activate, deactivate };
31 changes: 31 additions & 0 deletions editors/vscode/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "loadpath",
"displayName": "Loadpath",
"description": "Gutter marks for files on the current Loadpath review (seed, untested sink, contract).",
"version": "0.1.0",
"publisher": "loadpath",
"engines": { "vscode": "^1.85.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./extension.js",
"contributes": {
"commands": [
{ "command": "loadpath.refreshMarks", "title": "Loadpath: Refresh load-path marks" }
],
"configuration": {
"title": "Loadpath",
"properties": {
"loadpath.url": {
"type": "string",
"default": "http://127.0.0.1:7345",
"description": "Local Loadpath server origin (loadpath serve)."
},
"loadpath.repoPath": {
"type": "string",
"default": "",
"description": "Repository path sent to Loadpath. Empty uses the first workspace folder."
}
}
}
}
}
71 changes: 61 additions & 10 deletions src/loadpath/architecture/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from loadpath.architecture.depth import deepening_candidates
from loadpath.architecture.rules import evaluate
from loadpath.config import LoadpathConfig, load_config
from loadpath.graph.store import GraphStore, linked_edges
from loadpath.graph.store import GraphStore
from loadpath.index import default_db_path, index_drift
from loadpath.types import NodeType

Expand Down Expand Up @@ -43,7 +43,7 @@
}


def summarize_index(store: GraphStore, config: LoadpathConfig) -> dict[str, Any]:
def summarize_index(store: GraphStore, config: LoadpathConfig, *, hash_drift: bool = False) -> dict[str, Any]:
raw_findings = evaluate(store, config)
findings = [f.to_dict() for f in raw_findings]
residuals = [line for line in (store.get_meta("residuals") or "").splitlines() if line]
Expand All @@ -57,7 +57,7 @@ def summarize_index(store: GraphStore, config: LoadpathConfig) -> dict[str, Any]
}
for name, ctx in config.contexts.items()
}
drift = index_drift(store, config.repo_root, config)
drift = index_drift(store, config.repo_root, config, hash_contents=hash_drift)
boot_residuals = [line for line in residuals if "django.setup()" in line]
return {
"ok": True,
Expand Down Expand Up @@ -87,11 +87,55 @@ def summarize_index(store: GraphStore, config: LoadpathConfig) -> dict[str, Any]


def architecture_graph(store: GraphStore) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
nodes = [n for n in store.nodes() if n["type"] in ARCHITECTURE_NODE_TYPES]
return nodes, linked_edges(nodes, store.edges())
types = sorted(ARCHITECTURE_NODE_TYPES)
nodes = store.nodes(types)
return nodes, store.edges_between_types(types)


def architecture_report(repo_root: Path, db_path: Path | None = None) -> dict[str, Any]:
def workspace_index_card(repo_root: Path, db_path: Path | None = None) -> dict[str, Any]:
"""Counts and contexts only — used by GET /api/repos so listing workspaces is cheap."""
repo_root = repo_root.resolve()
db = db_path or default_db_path(repo_root)
has_config = (repo_root / "loadpath.yml").is_file()
empty_contexts: dict[str, Any] = {}
if not db.is_file():
return {
"indexed": False,
"counts": {"nodes": 0, "edges": 0},
"has_config": has_config,
"contexts": empty_contexts,
}
store = GraphStore(db)
config = load_config(repo_root)
card = {
"indexed": True,
"counts": store.counts(),
"indexed_at": store.get_meta("indexed_at"),
"has_config": has_config,
"contexts": {
name: {
"name": name,
"django_apps": ctx.django_apps,
"react": ctx.react,
"public_api": ctx.public_api,
"owners": ctx.owners,
}
for name, ctx in config.contexts.items()
},
"django_boot": store.get_meta("django_boot") or "off",
"stale": False,
}
store.close()
return card


def architecture_report(
repo_root: Path,
db_path: Path | None = None,
*,
include_graph: bool = True,
hash_drift: bool = False,
) -> dict[str, Any]:
repo_root = repo_root.resolve()
db = db_path or default_db_path(repo_root)
if not db.is_file():
Expand Down Expand Up @@ -123,12 +167,19 @@ def architecture_report(repo_root: Path, db_path: Path | None = None) -> dict[st
"residuals": [],
"nodes": [],
"edges": [],
"graph_pending": False,
}
store = GraphStore(db)
config = load_config(repo_root)
summary = summarize_index(store, config)
nodes, edges = architecture_graph(store)
summary["nodes"] = nodes
summary["edges"] = edges
summary = summarize_index(store, config, hash_drift=hash_drift)
if include_graph:
nodes, edges = architecture_graph(store)
summary["nodes"] = nodes
summary["edges"] = edges
summary["graph_pending"] = False
else:
summary["nodes"] = []
summary["edges"] = []
summary["graph_pending"] = True
store.close()
return summary
Loading
Loading