From 811c746ee16f7eee6bac13ab9a02b5e38f10fec5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 10:52:17 +0000 Subject: [PATCH 1/2] Lock the local API and GitHub Action for public use The UI HTTP API (settings, filesystem, review, PR comments) now rejects non-loopback Origin/Host so a tunneled MCP server cannot be CSRF'd from another site. MCP transport checks Host against loopback or --public-url. The composite action passes inputs through env vars and drops extra-args shell interpolation. Co-authored-by: zord.lack.net --- .github/workflows/ci.yml | 3 +++ .gitignore | 6 +++++ action.yml | 26 +++++++++++++-------- src/loadpath/mcp/server.py | 32 ++++++++++++++++++++++--- src/loadpath/server/app.py | 45 +++++++++++++++++++++++++++++++++--- tests/e2e/test_mcp_oauth.py | 3 +++ tests/unit/test_scm_oauth.py | 37 +++++++++++++++++++++++++++++ 7 files changed, 136 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fcf16f0..588c13c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,9 @@ on: branches: [main] pull_request: +permissions: + contents: read + jobs: test: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index d3b1f85..6e7d8b8 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,9 @@ ui/playwright-report/ ui/test-results/ desktop/backend-dist/ desktop/dist/ +.env.* +!.env.example +*.pem +id_rsa +id_rsa.pub +credentials.json diff --git a/action.yml b/action.yml index 5ea333d..c650d75 100644 --- a/action.yml +++ b/action.yml @@ -17,10 +17,6 @@ inputs: description: "Python version used to install Loadpath." required: false default: "3.12" - extra-args: - description: "Extra arguments passed to `loadpath review`." - required: false - default: "" outputs: level: description: "Confidence level (high, medium, low)." @@ -51,12 +47,22 @@ runs: shell: bash env: GITHUB_TOKEN: ${{ github.token }} + LOADPATH_FAIL_ON: ${{ inputs.fail-on }} + LOADPATH_COMMENT: ${{ inputs.comment }} + LOADPATH_PR: ${{ github.event.pull_request.number }} + LOADPATH_REPO: ${{ github.repository }} + LOADPATH_BASE_REF: ${{ github.base_ref }} run: | set -euo pipefail - ARGS=(review . --base "origin/${GITHUB_BASE_REF:-main}" --head HEAD --fail-on "${{ inputs.fail-on }}") - if [ "${{ inputs.comment }}" = "true" ] && [ -n "${{ github.event.pull_request.number }}" ]; then - ARGS+=(--comment --provider github --pr "${{ github.event.pull_request.number }}" --repo "${{ github.repository }}") + case "${LOADPATH_FAIL_ON}" in + never|blocker|low|medium) ;; + *) + echo "fail-on must be never, blocker, low, or medium" >&2 + exit 1 + ;; + esac + ARGS=(review . --base "origin/${LOADPATH_BASE_REF:-main}" --head HEAD --fail-on "${LOADPATH_FAIL_ON}") + if [ "${LOADPATH_COMMENT}" = "true" ] && [ -n "${LOADPATH_PR}" ]; then + ARGS+=(--comment --provider github --pr "${LOADPATH_PR}" --repo "${LOADPATH_REPO}") fi - # shellcheck disable=SC2206 - EXTRA=(${{ inputs.extra-args }}) - loadpath "${ARGS[@]}" "${EXTRA[@]}" + loadpath "${ARGS[@]}" diff --git a/src/loadpath/mcp/server.py b/src/loadpath/mcp/server.py index a78b9bd..b2b4ba0 100644 --- a/src/loadpath/mcp/server.py +++ b/src/loadpath/mcp/server.py @@ -3,6 +3,7 @@ import os from contextlib import asynccontextmanager from typing import Any, AsyncIterator +from urllib.parse import urlparse from pydantic import AnyHttpUrl from starlette.middleware.authentication import AuthenticationMiddleware @@ -13,7 +14,7 @@ from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend from mcp.server.auth.settings import AuthSettings, ClientRegistrationOptions, RevocationOptions from mcp.server.mcpserver import MCPServer -from mcp.server.transport_security import TransportSecuritySettings +from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings from loadpath import __version__ from loadpath.mcp import tools @@ -151,11 +152,36 @@ async def consent(request: Request) -> Response: return mcp -def build_mcp_http(mcp: MCPServer): +def mcp_transport_security(public_url: str | None = None) -> TransportSecuritySettings: + """Allow loopback (and an optional tunnel origin). Reject other Host/Origin values.""" + hosts = ["127.0.0.1:*", "localhost:*", "[::1]:*", "testserver", "testclient"] + origins = ["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"] + explicit = (public_url or os.environ.get("LOADPATH_PUBLIC_URL") or "").strip() + if explicit: + parsed = urlparse(explicit) + host = (parsed.hostname or "").lower().strip("[]") + if host and host not in {"127.0.0.1", "localhost", "::1"}: + hosts.extend([host, f"{host}:*"]) + if parsed.scheme: + origins.extend([f"{parsed.scheme}://{host}", f"{parsed.scheme}://{host}:*"]) + return TransportSecuritySettings( + enable_dns_rebinding_protection=True, + allowed_hosts=hosts, + allowed_origins=origins, + ) + + +def mcp_host_allowed(host_header: str, public_url: str | None = None) -> bool: + """True when Host is loopback, TestClient, or the optional tunnel hostname.""" + settings = mcp_transport_security(public_url) + return TransportSecurityMiddleware(settings)._validate_host(host_header or "") + + +def build_mcp_http(mcp: MCPServer, public_url: str | None = None): """Create the Streamable HTTP Starlette app (initializes session_manager).""" return mcp.streamable_http_app( streamable_http_path="/mcp", - transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False), + transport_security=mcp_transport_security(public_url), host="0.0.0.0", ) diff --git a/src/loadpath/server/app.py b/src/loadpath/server/app.py index 534ce50..1e49352 100644 --- a/src/loadpath/server/app.py +++ b/src/loadpath/server/app.py @@ -6,7 +6,8 @@ from fastapi import FastAPI, HTTPException, Query, Request from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import HTMLResponse, Response +from fastapi.responses import HTMLResponse, JSONResponse, Response +from starlette.middleware.base import BaseHTTPMiddleware from starlette.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field @@ -20,6 +21,7 @@ build_mcp_http, copy_mcp_routes, create_mcp_server, + mcp_host_allowed, mcp_lifespan, public_base_url, resource_url, @@ -234,9 +236,38 @@ def _call_scm(provider: str, fn): raise HTTPException(502, str(exc)) from exc +LOCAL_API_ONLY = "This action is only available from the local Loadpath UI" + + def require_loopback(request: Request) -> None: if not is_loopback_request(request.headers.get("host") or "", request.headers.get("origin") or ""): - raise HTTPException(403, "This action is only available from the local Loadpath UI") + raise HTTPException(403, LOCAL_API_ONLY) + + +class LoopbackAPIMiddleware(BaseHTTPMiddleware): + """Keep /api/* on the local UI. MCP OAuth stays on /mcp, /authorize, /token.""" + + async def dispatch(self, request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + if path.startswith("/api/") and path != "/api/health": + if not is_loopback_request(request.headers.get("host") or "", request.headers.get("origin") or ""): + return JSONResponse({"detail": LOCAL_API_ONLY}, status_code=403) + return await call_next(request) + + +class McpHostMiddleware(BaseHTTPMiddleware): + """Reject DNS-rebinding Host headers on MCP OAuth and transport paths.""" + + def __init__(self, app, public_url: str | None = None): + super().__init__(app) + self._public_url = public_url + + async def dispatch(self, request: Request, call_next): + path = request.url.path.rstrip("/") or "/" + if path == "/mcp" or path.startswith("/mcp/") or path in {"/authorize", "/token", "/register", "/revoke", "/consent"}: + if not mcp_host_allowed(request.headers.get("host") or "", self._public_url): + return JSONResponse({"detail": "Invalid Host header"}, status_code=421) + return await call_next(request) def _attach_loadpath(listed: list[dict[str, Any]], repo_path: str | None) -> list[dict[str, Any]]: @@ -276,7 +307,7 @@ def create_app( oauth_pin=oauth_pin, auto_approve=oauth_auto_approve, ) - mcp_http = build_mcp_http(mcp) + mcp_http = build_mcp_http(mcp, public_url=base) app = FastAPI(title="Loadpath", version=__version__, lifespan=mcp_lifespan(mcp)) app.state.mcp = mcp app.state.mcp_http = mcp_http @@ -290,6 +321,8 @@ def create_app( allow_headers=["*"], expose_headers=["WWW-Authenticate", "Mcp-Session-Id", "mcp-session-id"], ) + app.add_middleware(LoopbackAPIMiddleware) + app.add_middleware(McpHostMiddleware, public_url=base) @app.get("/api/health") def health() -> dict[str, str]: @@ -951,6 +984,12 @@ def serve( display = "127.0.0.1" if host in {"0.0.0.0", "::", "[::]"} else host base = public_base_url(host=host, port=port, public_url=public_url) application = create_app(public_url=base, oauth_pin=oauth_pin) + if host in {"0.0.0.0", "::", "[::]"}: + print( + "Listening on all interfaces. The UI at /api stays loopback-only; " + "remote clients should use MCP at /mcp. Prefer --oauth-pin when --public-url is set.", + flush=True, + ) if open_browser: webbrowser.open(f"http://{display}:{port}") uvicorn.run(application, host=host, port=port, reload=False) diff --git a/tests/e2e/test_mcp_oauth.py b/tests/e2e/test_mcp_oauth.py index addb234..fa7a510 100644 --- a/tests/e2e/test_mcp_oauth.py +++ b/tests/e2e/test_mcp_oauth.py @@ -49,6 +49,9 @@ def test_oauth_metadata_and_mcp_requires_bearer(tmp_path, monkeypatch): assert prm.json()["resource"].endswith("/mcp") assert prm.json()["authorization_servers"] == [body["issuer"]] + denied = client.post("/mcp", headers={"host": "evil.example"}) + assert denied.status_code == 421 + denied = client.post("/mcp", json={"jsonrpc": "2.0", "id": 1, "method": "ping"}) assert denied.status_code == 401 assert "resource_metadata" in denied.headers.get("www-authenticate", "").lower() diff --git a/tests/unit/test_scm_oauth.py b/tests/unit/test_scm_oauth.py index 123bd38..a6f6167 100644 --- a/tests/unit/test_scm_oauth.py +++ b/tests/unit/test_scm_oauth.py @@ -239,8 +239,25 @@ def test_github_oauth_start_requires_client_id(tmp_path, monkeypatch): def test_scm_routes_reject_cross_origin(tmp_path, monkeypatch): client = _client(tmp_path, monkeypatch) headers = {"Origin": "https://evil.example"} + assert client.get("/api/health", headers=headers).status_code == 200 assert client.get("/api/scm/repos", params={"provider": "github"}, headers=headers).status_code == 403 assert client.get("/api/oauth/status", headers=headers).status_code == 403 + assert client.get("/api/settings", headers=headers).status_code == 403 + assert client.get("/api/fs", headers=headers).status_code == 403 + assert client.get("/api/repos", headers=headers).status_code == 403 + assert client.put("/api/settings", json={"github_token": "ghp_x"}, headers=headers).status_code == 403 + assert client.post("/api/index", json={"repo_path": "/tmp"}, headers=headers).status_code == 403 + assert client.post( + "/api/review", + json={"repo_path": "/tmp", "base": "HEAD~1"}, + headers=headers, + ).status_code == 403 + assert client.post( + "/api/prs/comment", + json={"provider": "github", "repo": "acme/demo", "number": 1, "markdown": "x"}, + headers=headers, + ).status_code == 403 + assert client.post("/api/ai/residual", json={"review": {}}, headers=headers).status_code == 403 assert client.post("/api/oauth/github/start", headers=headers).status_code == 403 assert client.post("/api/oauth/disconnect", json={"provider": "github"}, headers=headers).status_code == 403 assert client.put( @@ -270,6 +287,26 @@ def test_loopback_request_helper(): assert not is_loopback_request("tunnel.example", "") +def test_mcp_transport_security_allows_loopback_and_tunnel(): + from loadpath.mcp.server import mcp_transport_security + + local = mcp_transport_security("http://127.0.0.1:7345") + assert local.enable_dns_rebinding_protection is True + assert "127.0.0.1:*" in local.allowed_hosts + assert "evil.example" not in local.allowed_hosts + + tunneled = mcp_transport_security("https://loadpath.example") + assert "loadpath.example" in tunneled.allowed_hosts + assert "https://loadpath.example" in tunneled.allowed_origins + + from loadpath.mcp.server import mcp_host_allowed + + assert mcp_host_allowed("testserver", None) + assert mcp_host_allowed("127.0.0.1:7345", None) + assert not mcp_host_allowed("evil.example", None) + assert mcp_host_allowed("loadpath.example", "https://loadpath.example") + + def test_github_device_rejects_unexpected_verification_url(): import pytest From 69b2c00e190139dbcf7ccb71971f3355f1a0e177 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 10:52:21 +0000 Subject: [PATCH 2/2] Rewrite the README and add public-release community files Lead with what Loadpath does and does not do, then clone-to-run setup for CLI, UI, Action, MCP, and desktop. Add a Code of Conduct, issue templates, Dependabot, and a security policy that matches the loopback API model. Co-authored-by: zord.lack.net --- .github/ISSUE_TEMPLATE/bug.yml | 31 +++ .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature.yml | 17 ++ .github/dependabot.yml | 18 ++ CODE_OF_CONDUCT.md | 78 ++++++ CONTRIBUTING.md | 8 + README.md | 432 ++++++++++++++--------------- SECURITY.md | 29 +- editors/vscode/README.md | 2 +- pyproject.toml | 6 +- 10 files changed, 390 insertions(+), 236 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature.yml create mode 100644 .github/dependabot.yml create mode 100644 CODE_OF_CONDUCT.md diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..1e6d9bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,31 @@ +name: Bug +description: Something does not work as documented +title: "[Bug] " +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for the report. Do **not** include tokens, `~/.loadpath/settings.json`, or private repo contents. + - type: textarea + id: expected + attributes: + label: What happened + description: What you did, what you expected, and what you got. + validations: + required: true + - type: textarea + id: repro + attributes: + label: Repro + description: Commands, `loadpath.yml` (redacted), and stack if you have one. + render: shell + validations: + required: true + - type: input + id: version + attributes: + label: Version + description: `loadpath --version` or git SHA. + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d03483f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Security report + url: https://github.com/Modsofthenation/PR-Reviewer/security/advisories/new + about: Report a vulnerability privately. Do not file a public issue. diff --git a/.github/ISSUE_TEMPLATE/feature.yml b/.github/ISSUE_TEMPLATE/feature.yml new file mode 100644 index 0000000..3914224 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature.yml @@ -0,0 +1,17 @@ +name: Feature +description: Ask for an extractor, rule, or workflow Loadpath does not have +title: "[Feature] " +labels: ["enhancement"] +body: + - type: textarea + id: request + attributes: + label: What do you want + description: The load path you cannot inspect today, and why a hunk-comment bot would not be enough. + validations: + required: true + - type: textarea + id: stack + attributes: + label: Stack + description: Django / React surfaces involved (DRF, Celery, Next.js, …). diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..baa8f39 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,18 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: pip + directory: / + schedule: + interval: weekly + - package-ecosystem: npm + directory: /ui + schedule: + interval: weekly + - package-ecosystem: npm + directory: /desktop + schedule: + interval: weekly diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..4c8d2fa --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,78 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances of + any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email address, + without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. + +## Enforcement + +Report incidents to the repository owner on GitHub (`Modsofthenation`). For +security issues, use [SECURITY.md](SECURITY.md) instead of a public issue. + +All complaints will be reviewed and investigated promptly and fairly. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +1. **Correction** — private warning. +2. **Warning** — warning with consequences for continued behavior. +3. **Temporary ban** — temporary ban from community interaction. +4. **Permanent ban** — permanent ban from community interaction. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +https://www.contributor-covenant.org/version/2/1/code_of_conduct.html. + +[homepage]: https://www.contributor-covenant.org diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ebf27b..a5156ee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,16 +1,24 @@ # Contributing +By participating you agree to the [Code of Conduct](CODE_OF_CONDUCT.md). Security reports go to [SECURITY.md](SECURITY.md), not the public issue tracker. + ## Setup Python 3.12+ and Node 22+. ```bash +git clone https://github.com/Modsofthenation/PR-Reviewer.git +cd PR-Reviewer +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -e ".[dev]" python -m playwright install chromium # optional; needed for UI screenshot tests cd ui && npm install && npm run build && cd .. loadpath --help ``` +Do not commit `~/.loadpath/`, `.env`, tokens, or private clones. Settings already live outside the repo. + ## Tests ```bash diff --git a/README.md b/README.md index d1351a5..0dab932 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ [![CI](https://github.com/Modsofthenation/PR-Reviewer/actions/workflows/ci.yml/badge.svg)](https://github.com/Modsofthenation/PR-Reviewer/actions/workflows/ci.yml) -Review as **load-path inspection** on a Django + React architecture graph. Not another hunk-comment bot. +Local **load-path inspection** for Django + React pull requests. A change is a force: Loadpath traces where that force travels until it hits a sink (HTTP, UI, Celery/Dramatiq, migration, permission), then scores whether you have enough evidence to merge. -A change is a force. Loadpath traces where that force travels until it hits a sink — HTTP response, UI, Celery/Dramatiq job, migration, permission — then scores whether you have enough evidence to merge. +It is a CLI, a local desktop UI, a GitHub Action merge gate, and an MCP server you can point Cursor at. Not a SaaS. Not a hunk-comment bot. ``` Loadpath: MEDIUM — Invoice.total field change @@ -21,118 +21,154 @@ On the demo monorepo that path is: plus the jobs the view enqueues (`send_invoice_email.delay`, `rebuild_ledger.send`). -![Review screen with impact graph](docs/screenshots/review.png) +## What it does -It is a local CLI, a desktop UI, and an MCP server you can point Cursor at. Not a SaaS. Tokens stay on the machine in `~/.loadpath/settings.json`. +- Indexes a Django + React repo into a typed architecture graph (AST overlay, not an import graph). +- Reviews a git range against that graph: sinks, tests, contract drift, auth, suggested reviewers. +- Shows the same walk in a local UI (2D map, optional 3D when WebGL is available). +- Optionally upserts **one** PR comment (updated in place) and fails CI on architecture blockers. +- Speaks MCP so an editor can ask for the brief without dumping the full graph. -## App +## What it does not do -`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. +- **Not a general reviewer bot.** It does not comment every hunk, suggest nits, or replace human review. +- **Not a SaaS.** Nothing is uploaded. Tokens stay in `~/.loadpath/` on the machine that runs Loadpath. +- **Not a linter, SAST scanner, or test runner.** It does not execute your app or your test suite. +- **Not a generic call graph.** Other stacks (Rails, JVM, …) are out of scope. FastAPI / GraphQL / HTMX are overlays inside a Django+React repo, not standalone products. +- **Not a runtime tracer.** Edges come from extractors (AST, OpenAPI, generated clients). Dashed edges are inferred. +- **Not CodeScene / django-orm-lens / SCIP.** Churn and N+1 heuristics are scored on the load path only. -### Empty review +MIT. See [LICENSE](LICENSE). Conduct: [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). Vulnerabilities: [SECURITY.md](SECURITY.md). -Until a repo is indexed and a range is walked, Review is an onboarding card — not a blank graph. +## Requirements -![Empty review onboarding](docs/screenshots/review-empty.png) - -### Review - -Confidence brief, read-order, clusters, architecture findings on the impact path, residual list, and the subgraph for the git range. Review walks the **indexed** graph (incremental refresh by default). - -![Review with brief and impact graph](docs/screenshots/review.png) - -### Node inspector - -Click a node. The inspector answers *what is this, what feeds it, what does it call, what would break* — not a dump of the indexer row. - -![Inspector on InvoiceSerializer](docs/screenshots/review-inspector.png) - -### Architecture - -Index a repo first. The architecture tab is the full typed graph plus `loadpath.yml` contexts and rules — not a PR diff. Findings here are repo-wide; review then scopes them to the change. +- Python **3.12+** +- Node **22+** (to build the UI; the served app is static files) +- Git +- A Django + React repo (or the bundled demo) -![Architecture graph and findings](docs/screenshots/architecture.png) +## Install -### Impact graph +Clone and install from source (there is no PyPI package yet): -Toggle **This review** (impact subgraph) vs **Indexed architecture** (the repo map). Dashed edges are inferred (URL/Zod overlap); solid edges are extracted or generated-client stitches. 2D is the default. 3D uses the same layout algorithms, puts bounded context on the depth axis, and is available when WebGL is. +```bash +git clone https://github.com/Modsofthenation/PR-Reviewer.git +cd PR-Reviewer +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -e ".[dev]" +cd ui && npm install && npm run build && cd .. +loadpath --help +``` -![Impact graph, this review](docs/screenshots/graph.png) +`python -m playwright install chromium` is optional (UI screenshot tests only). -![Indexed architecture in the graph tab](docs/screenshots/graph-architecture.png) +## Quick start (demo) -### Pull requests +The fixture at [`fixtures/demo_monorepo`](fixtures/demo_monorepo) is not a git repo. Copy it and make **two** commits so `HEAD~1`…`HEAD` is a real range: -GitHub, GitLab, and Bitbucket via **Sign in** (OAuth) or a token in Settings. GitHub Enterprise and self-hosted GitLab take a host in Settings. After connecting, **My repos** lists every repository the account can access. Pick one and **Review this PR** — Loadpath fetches the PR refs into a local clone if this machine does not already have one. Review still runs against that clone. The screenshot uses a fixture PR so the tab is not empty. +```bash +cp -R fixtures/demo_monorepo /tmp/acme-billing +cd /tmp/acme-billing +git init -b main +git add -A && git commit -m "baseline" +python3 - <<'PY' +from pathlib import Path +p = Path("backend/billing/serializers.py") +p.write_text(p.read_text().replace( + 'fields = ["id", "customer_id", "total", "status"]', + 'fields = ["id", "customer_id", "total", "status"]\n extra_kwargs = {"total": {"required": True}}', +)) +PY +git add -A && git commit -m "tighten Invoice.total contract" -![Pull requests list](docs/screenshots/pull-requests.png) +loadpath index /tmp/acme-billing +loadpath review /tmp/acme-billing --base HEAD~1 --head HEAD +loadpath serve --open +``` -### Repo explorer +Point the UI at `/tmp/acme-billing` (or pick it in the repo explorer). Default range is `HEAD~1`…`HEAD`. -Browse the filesystem, pick a project root. Loadpath remembers recent workspaces. +**Flow:** `index` builds the graph → `architecture` surveys the repo → `review` walks a git range. The app mirrors this: Index, Architecture, Review. -![Repo explorer](docs/screenshots/explorer.png) +## Run -### Settings +### CLI -Appearance (all 24 themes), GitHub / GitLab / Bitbucket **OAuth sign-in** (or a classic PAT / app password), GitHub Enterprise host, and AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama). Residual analysis only — Loadpath does not comment every hunk. +```bash +loadpath init /path/to/repo # draft loadpath.yml (never overwrites) +loadpath index /path/to/repo # SQLite graph at .loadpath/graph.sqlite3 +loadpath architecture /path/to/repo +loadpath review /path/to/repo --base HEAD~1 --head HEAD +loadpath review /path/to/repo --base origin/main --head HEAD --no-reindex +loadpath review /path/to/repo --dirty # include the working tree +loadpath review /path/to/repo --fail-on blocker # never | blocker | low | medium +loadpath whatif /path/to/repo django.field:billing.Invoice.total +loadpath serve --port 7345 # UI + API + MCP /mcp +loadpath mcp # stdio MCP for Cursor (no OAuth) +``` -GitHub uses device flow (`repo read:user read:org`). Create an OAuth App, enable Device Flow, then set `LOADPATH_GITHUB_CLIENT_ID` or paste the client ID in Settings. For GitHub Enterprise, set the host (API is `{host}/api/v3`). +Put `loadpath.yml` at the repo root (see [`loadpath.yml.example`](loadpath.yml.example)). -GitLab uses authorization code. Create an OAuth application whose callback is `http://127.0.0.1:7345/api/oauth/gitlab/callback`, then set `LOADPATH_GITLAB_CLIENT_ID` / `LOADPATH_GITLAB_CLIENT_SECRET` (or paste them in Settings). Self-managed GitLab takes a host. +### Local UI -Bitbucket uses authorization code. Create an OAuth consumer whose callback is `http://127.0.0.1:7345/api/oauth/bitbucket/callback`, then set `LOADPATH_BITBUCKET_CLIENT_ID` / `LOADPATH_BITBUCKET_CLIENT_SECRET` or paste the key and secret in Settings. Access tokens are refreshed automatically. SCM sign-in and repo listing are local-only (loopback) so a tunneled MCP server does not expose private repos. +`loadpath serve` binds **127.0.0.1:7345** and opens the app. Icon rail, merge-box confidence, inspectable graph. Copy markdown, save HTML, or post **one** PR comment from Review. -![Settings with theme grid](docs/screenshots/settings.png) +Keyboard: `1`–`5` tabs. `⌘`/`Ctrl`+`K` command palette. `j`/`k` read-order. `⌘`/`Ctrl`+`Enter` runs a review (outside Settings / Pull requests). -### MCP consent +### GitHub Action merge gate -When Cursor (or another client) connects over HTTP MCP, Loadpath does not silently grant access. You get a local consent page: client name, Loadpath issuer URL, optional PIN. Approve or deny. Tokens stay in `~/.loadpath/oauth.json`. +```yaml +permissions: + contents: read + pull-requests: write # only needed when comment: true +jobs: + loadpath: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: Modsofthenation/PR-Reviewer@main + with: + fail-on: blocker # never | blocker | low | medium + comment: true +``` -![MCP consent](docs/screenshots/mcp-consent.png) +Indexes the checkout, reviews `origin/$GITHUB_BASE_REF...HEAD`, optionally upserts the brief, fails on architecture blockers (or on low/medium confidence if you ask). -## Themes +Outputs: `level`, `passed`, `title`, `contract_break` (`none` | `additive` | `breaking` | `drift`). -Default is **Obsidian**. Settings lists every palette; the shots below are the same Review screen under nine of them. +CLI equivalent: `loadpath review . --fail-on blocker --comment --provider github --pr $N --repo $SLUG`. - - - - - - - - - - - - - - - - -
Obsidian
Obsidian theme
Nord
Nord theme
Neon noir
Neon noir theme
Synthwave
Synthwave theme
Phosphor
Phosphor theme
Paper
Paper theme
Sakura
Sakura theme
Citrus
Citrus theme
High contrast
High contrast theme
+### MCP (Cursor, Claude, ChatGPT, Gemini) -Also shipping: Solarized Dark/Light, Forest, Rose Pine, Midnight Amber, Volcano, Lavender, Aurora, Biolume, Carbon, Seafoam, Peach Fuzz, Cotton Candy, Clear Sky, Coral Reef. High contrast is a first-class theme, not an afterthought. +**Local stdio** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`: -## Install +```json +{ + "mcpServers": { + "loadpath": { + "command": "loadpath", + "args": ["mcp"] + } + } +} +``` -Python 3.12+ and Node 22+ (UI). +**HTTP + OAuth** — `loadpath serve` exposes Streamable HTTP MCP at `/mcp`. Cloud hosts need HTTPS. Set `--public-url` when tunneling, and `--oauth-pin` so random clients cannot approve themselves: ```bash -pip install -e ".[dev]" -python -m playwright install chromium # optional, for UI screenshot tests -cd ui && npm install && npm run build && cd .. -loadpath --help +loadpath serve --host 0.0.0.0 --port 7345 --public-url https://your-tunnel.example --oauth-pin 123456 ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) for tests, screenshot regeneration, and the Electron app. +The UI remains on `http://127.0.0.1:7345`. The tunnel is for MCP only. First connect opens a consent page on this machine. -## Desktop app (Windows, macOS, Linux) +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 brief — not hunk comments. `load_path_marks` feeds the Cursor/VS Code gutter in [`editors/vscode`](editors/vscode). -Electron wraps the same local app: it starts the Loadpath backend and opens it in a native window. Tokens still live in `~/.loadpath/settings.json`. +### Desktop app (Windows, macOS, Linux) -**From source** +Electron wraps the same local server. Tokens still live in `~/.loadpath/settings.json`. ```bash pip install -e . @@ -140,116 +176,82 @@ cd ui && npm install && npm run build && cd .. cd desktop && npm install && npm start ``` -Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or set `LOADPATH_PYTHON`. - -**Installers** +Requires Python 3.12+ on `PATH` (`python` on Windows, `python3` elsewhere), or `LOADPATH_PYTHON`. -GitHub → Actions → **Desktop builds** → **Run workflow**. That manual job builds: +Installers: GitHub → Actions → **Desktop builds** → **Run workflow** (Linux AppImage/`.deb`, Windows NSIS, macOS unsigned `.dmg`/`.zip`). Gatekeeper needs right-click → Open on macOS. -| OS | Artifact | -| --- | --- | -| Linux | AppImage and `.deb` | -| Windows | NSIS `.exe` | -| macOS | `.dmg` and `.zip` (unsigned) | +## Security -macOS Gatekeeper will block the unsigned app until you open it from Finder with right-click → Open. The workflow smokes `/api/health` on the bundled Python sidecar before packaging. +- Default bind is loopback. `/api/*` except `/api/health` rejects non-local `Origin`/`Host` (settings, filesystem browse, review, PR comments, AI). +- SCM OAuth, tokens, and indexes stay in `~/.loadpath/` (`0700` / `0600`). +- Do not set `LOADPATH_OAUTH_AUTO_APPROVE=1` outside tests. +- Report vulnerabilities privately: [SECURITY.md](SECURITY.md). -## CLI +## The app -```bash -# Detect Django/React roots and draft loadpath.yml (never overwrites an existing file) -loadpath init /path/to/repo +Until a repo is indexed and a range is walked, Review is an onboarding card. -# Index a monorepo (SQLite graph at .loadpath/graph.sqlite3; unchanged hashes skip extract) -loadpath index /path/to/repo +![Empty review onboarding](docs/screenshots/review-empty.png) -# Inspect bounded contexts, rules, and type counts from that index -loadpath architecture /path/to/repo +![Review with brief and impact graph](docs/screenshots/review.png) -# Review a git range against the index (three-dot / merge-base by default) -loadpath review /path/to/repo --base HEAD~1 --head HEAD -loadpath review /path/to/repo --base origin/main --head HEAD --no-reindex -loadpath review /path/to/repo --dirty # include the working tree -loadpath review /path/to/repo --fail-on blocker # CI merge gate (never|blocker|low|medium) +**Node inspector** — click a node: what it is, what feeds it, what it calls. -# Walk sinks from one indexed node — no git range -loadpath whatif /path/to/repo django.field:billing.Invoice.total +![Inspector on InvoiceSerializer](docs/screenshots/review-inspector.png) -# Cross-platform app (API + visual graph + PR list + MCP /mcp with OAuth) -loadpath serve --port 7345 +**Architecture** — full typed graph plus `loadpath.yml` contexts. Findings here are repo-wide; review scopes them to the change. -# Local stdio MCP for Cursor / Claude Desktop (no OAuth) -loadpath mcp -``` +![Architecture graph and findings](docs/screenshots/architecture.png) -**Flow:** `index` builds the architecture graph → `architecture` shows contexts and rule hits on the whole repo → `review` walks that same graph for a git range. The app mirrors this: Index registers a workspace, Architecture inspects it, Review traces a change through it. +**Impact graph** — **This review** vs **Indexed architecture**. Dashed edges are inferred; solid edges are extracted. 2D is the default. 3D uses the same layouts with bounded context on the depth axis when WebGL is available. -The bundled demo checkout is [`fixtures/demo_monorepo`](fixtures/demo_monorepo) (Django billing API + React invoice UI). Copy it and make **two** commits so `HEAD~1` is a real range — the fixture itself is not a git repo, and a single commit leaves the default range empty. +![Impact graph, this review](docs/screenshots/graph.png) -```bash -cp -R fixtures/demo_monorepo /tmp/acme-billing -cd /tmp/acme-billing -git init -b main -git add -A && git commit -m "baseline" -# Same contract tweak the screenshots use: -python3 - <<'PY' -from pathlib import Path -p = Path("backend/billing/serializers.py") -p.write_text(p.read_text().replace( - 'fields = ["id", "customer_id", "total", "status"]', - 'fields = ["id", "customer_id", "total", "status"]\n extra_kwargs = {"total": {"required": True}}', -)) -PY -git add -A && git commit -m "tighten Invoice.total contract" -loadpath index /tmp/acme-billing -loadpath serve --open -``` +![Indexed architecture in the graph tab](docs/screenshots/graph-architecture.png) -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). +**Pull requests** — GitHub, GitLab, Bitbucket via **Sign in** (OAuth) or a token. GitHub Enterprise and self-hosted GitLab take a host in Settings. **Review this PR** fetches refs into a local clone. Sign-in and repo listing are loopback-only. -## GitHub Action merge gate +![Pull requests list](docs/screenshots/pull-requests.png) -Use this repo as a composite action. It indexes the checkout, reviews `origin/$GITHUB_BASE_REF...HEAD`, optionally upserts the single Loadpath brief, and fails the job on architecture blockers (or on low/medium confidence if you ask). +**Repo explorer** — pick a project root. Loadpath remembers recent workspaces. -```yaml -- uses: Modsofthenation/PR-Reviewer@main - with: - fail-on: blocker # never | blocker | low | medium - comment: true -``` +![Repo explorer](docs/screenshots/explorer.png) -Outputs: `level`, `passed`, `title`, `contract_break` (`none` | `additive` | `breaking` | `drift`). +**Settings** — 24 themes; GitHub device flow (`repo read:user read:org`); GitLab / Bitbucket authorization-code (callback `http://127.0.0.1:7345/api/oauth//callback`); AI providers (Anthropic, OpenAI, Grok/xAI, DeepSeek, Cursor-compatible, Ollama) for **residual uncertainty only**. -CLI equivalent: `loadpath review . --fail-on blocker --comment --provider github --pr $N --repo $SLUG`. `--github-output` (or `GITHUB_OUTPUT`) writes the same fields for Actions. +Set `LOADPATH_GITHUB_CLIENT_ID` (enable Device Flow on the OAuth App) or paste the client ID in Settings. GitLab/Bitbucket: `LOADPATH_*_CLIENT_ID` / `LOADPATH_*_CLIENT_SECRET`. -## MCP (Cursor, Claude, ChatGPT, Gemini) +![Settings with theme grid](docs/screenshots/settings.png) -`loadpath serve` exposes Streamable HTTP MCP at `/mcp`, protected with OAuth 2.1 (PKCE, dynamic client registration, Client ID Metadata Documents). Cloud hosts need HTTPS; set `--public-url` to the public origin when tunneling. `--oauth-pin` adds a PIN on the consent page. +**MCP consent** — HTTP MCP does not silently grant access. Tokens in `~/.loadpath/oauth.json`. -```bash -loadpath serve --host 0.0.0.0 --port 7345 --public-url https://your-tunnel.example --oauth-pin 123456 -``` +![MCP consent](docs/screenshots/mcp-consent.png) -MCP URL: `https://your-tunnel.example/mcp` (or `http://127.0.0.1:7345/mcp` on the same machine). +Watch the working tree to re-walk on save. Click a node → **What if this changes** to walk sinks with no git range. Isolate path to sinks filters the current map. -**Cursor (stdio, local)** — `~/.cursor/mcp.json` or project `.cursor/mcp.json`: +### Themes -```json -{ - "mcpServers": { - "loadpath": { - "command": "loadpath", - "args": ["mcp"] - } - } -} -``` - -**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. +Default is **Obsidian**. Nine of the twenty-four palettes: -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. + + + + + + + + + + + + + + + + +
Obsidian
Obsidian theme
Nord
Nord theme
Neon noir
Neon noir theme
Synthwave
Synthwave theme
Phosphor
Phosphor theme
Paper
Paper theme
Sakura
Sakura theme
Citrus
Citrus theme
High contrast
High contrast theme
-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. +Also shipping: Solarized Dark/Light, Forest, Rose Pine, Midnight Amber, Volcano, Lavender, Aurora, Biolume, Carbon, Seafoam, Peach Fuzz, Cotton Candy, Clear Sky, Coral Reef. ## Django support @@ -257,55 +259,37 @@ AST is the default extractor. It is a **framework overlay**, not an import graph | Surface | What Loadpath extracts | | --- | --- | -| Models | Fields, FK / M2M / O2O, `on_delete`, string refs (`ForeignKey("accounts.User")`) as residuals | -| Serializers | `Meta.fields` / `exclude`, declared fields, nested serializers, `SerializerMethodField`, parsed `to_representation` keys, `serializes` edges, queryset-in-serializer flag | -| Views | DRF ViewSets / APIViews, `serializer_class`, `get_serializer_class` (resolved returns; residual only when unresolved), `permission_classes`, `get_queryset`, `filterset_class`, `authentication_classes`, `pagination_class` | +| Models | Fields, FK / M2M / O2O, `on_delete`, string refs as residuals | +| Serializers | `Meta.fields` / `exclude`, nested serializers, `SerializerMethodField`, `serializes` edges | +| Views | DRF ViewSets / APIViews, `serializer_class`, `permission_classes`, `get_queryset`, … | | Function views | `@api_view`, `@login_required`, `@csrf_exempt`, … | -| Django Ninja | `@router.get/post/…` routes and views, `Schema` / `ModelSchema` fields (including nested), response annotation → schema | -| FastAPI (same repo) | `@app.get/post/…` and Pydantic `BaseModel` (nested annotations) — only when the file imports FastAPI, so Ninja is not stolen | -| GraphQL | Strawberry `@strawberry.type` / `@strawberry.field` and Graphene `ObjectType` / `Mutation`; client `gql` documents stitch by operation/selection name | -| Channels | `WebsocketConsumer` subclasses and `path(..., Consumer.as_asgi())` websocket routes | -| Templates + HTMX | `.html` files, `{% url %}` / include / extends, `hx-get/post/…` stitched to Django routes | -| Cache / flags / on_commit | `cache.get/set/delete`, waffle-style `flag_is_active`, `transaction.on_commit` as sinks | -| URLs | `path` / `re_path`, DRF `router.register`, `include()` mount composition (`/api` + `invoices//` → `/api/invoices/{id}`) | -| Signals | `@receiver`, `signal.connect()` residual, `AppConfig.ready()` residual | -| Management commands | `BaseCommand` + `handle()`, including `.delay(` / `.send(` enqueue edges | -| Migrations | `CreateModel` / `AddField` / `RemoveField` / `DeleteModel` / `RunPython` as `destructive_migration` | -| Tests | `test_*` in `tests.py` / `tests/` as `tested_by` | - -### Celery - -- `@shared_task`, `@app.task`, `@periodic_task` -- `celery.Task` subclasses (`run(self, invoice_id)`) -- Enqueue: `.delay(`, `.apply_async(` -- Signatures / canvas: `.s(`, `.si(`, `chain` / `group` / `chord` (canvas is a residual; inner signatures are `enqueues` edges) -- `current_app.send_task("billing.tasks.send_invoice_email")` — residual + inferred enqueue -- `transaction.on_commit(lambda: task.apply_async(...))` — `SIDE_EFFECT` sink + residual; nested enqueue walked -- `CELERY_BEAT_SCHEDULE` / `beat_schedule` in settings - -### Dramatiq - -- `@dramatiq.actor` -- `dramatiq.GenericActor` subclasses (`perform(self, invoice_id)`) -- Enqueue: `.send(`, `.send_with_options(` (heuristic: dramatiq import or `actors` / `tasks` module) - -Call-site placeholders (`rebuild_ledger.send` in a view) do **not** overwrite the actor definition’s file. The graph keeps `actors.py` / `tasks.py` as the node home. - -### Idempotency rule - -`celery_tasks_must_be_idempotent_on_model_pk` (alias `async_tasks_must_be_idempotent_on_model_pk`) warns when a Celery **or** Dramatiq task takes a full object payload instead of `pk` / `*_id`. Message names the broker. - -### Optional `django.setup()` overlay - -AST is enough for review. If you need live `_meta` (db_table, resolved relations), set `boot_django: true` in `loadpath.yml`. Loadpath then imports Django, calls `django.setup()`, and merges model/field nodes. Failures become residuals; the AST graph still stands. Leave it `false` in CI unless the fixture is a bootable project. +| Django Ninja | `@router.get/post/…`, `Schema` / `ModelSchema` | +| FastAPI (same repo) | `@app.get/post/…` and Pydantic `BaseModel` — only when the file imports FastAPI | +| GraphQL | Strawberry / Graphene; client `gql` documents stitch by operation name | +| Channels | `WebsocketConsumer` and websocket routes | +| Templates + HTMX | `{% url %}`, `hx-get/post/…` stitched to Django routes | +| Cache / flags / on_commit | `cache.get/set`, waffle-style flags, `transaction.on_commit` as sinks | +| URLs | `path` / `re_path`, DRF routers, `include()` composition | +| Signals | `@receiver`, `signal.connect()` residual | +| Management commands | `BaseCommand` + enqueue edges | +| Migrations | `CreateModel` / `AddField` / `RemoveField` / `DeleteModel` / `RunPython` | +| Tests | `test_*` as `tested_by` | +| Celery | `@shared_task` / `@app.task`, `.delay(` / `.apply_async(`, canvas residual, beat schedule | +| Dramatiq | `@dramatiq.actor`, `.send(` / `.send_with_options(` | + +Call-site placeholders do **not** overwrite the actor definition’s file. + +`celery_tasks_must_be_idempotent_on_model_pk` (alias `async_tasks_must_be_idempotent_on_model_pk`) warns when a task takes a full object payload instead of `pk` / `*_id`. + +Optional `boot_django: true` in `loadpath.yml` imports Django for live `_meta`. Leave it `false` in CI unless the fixture is bootable. Failures become residuals; the AST graph still stands. ## React + stitch -**React:** react-router tables, Next.js App Router (`app/**/page.tsx`) and Pages Router, Server Actions, composition, TanStack Query `queryKey` + fetch/axios URL templates, RTK Query `createApi` endpoints, openapi-fetch `client.GET/POST`, tRPC procedures, ts-rest `path:` contracts, Zod schemas, GraphQL codegen types, feature-folder imports, RTL `render()` and Playwright/Cypress `page.goto` / `cy.visit` as `tested_by`. +**React:** react-router, Next.js App/Pages Router, Server Actions, TanStack Query, RTK Query, openapi-fetch, tRPC, ts-rest, Zod, GraphQL codegen, RTL / Playwright / Cypress as `tested_by`. -**Stitch (the moat):** OpenAPI from Spectacular/schema files first; generated clients (`generated/`, orval, openapi-typescript) and typed clients (RTK Query, openapi-fetch, ts-rest, tRPC) as high-confidence `consumed_by_client`; FastAPI routes, Ninja/Pydantic schemas, and GraphQL operations (including codegen types) stitch the same way; HTMX URLs and Playwright/Cypress visits match Django/React routes; fallback URL-template matching and serializer/Zod field overlap marked **inferred**. +**Stitch:** OpenAPI from Spectacular/schema files; generated clients as high-confidence `consumed_by_client`; HTMX and e2e visits match routes; URL-template / Zod overlap marked **inferred**. -Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/src`, `src-ui/src` — not a Python package `src/` and not `docs` / docs-site trees. `app/` is a Django root candidate. +Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/src` — not a Python package `src/`. `app/` is a Django root candidate. ## Architecture rules (`loadpath.yml`) @@ -317,19 +301,17 @@ Frontend roots prefer `frontend/src`, `frontend`, `web/src`, `client/src`, `ui/s | `no_queryset_in_serializer` | Serializers must not run querysets | | `celery_tasks_must_be_idempotent_on_model_pk` | Celery and Dramatiq tasks take a model pk | | `queryset_nplusone` | Loops over querysets that touch related objects need `select_related` / `prefetch_related` | -| `queryset_missing_index` | `.filter()` / `.order_by()` on a field that has no `db_index` / `unique` | +| `queryset_missing_index` | `.filter()` / `.order_by()` on a field with no `db_index` / `unique` | | `cascade_crosses_context` | `on_delete=CASCADE` must not blast into another bounded context | | `migration_blast_radius` | `RemoveField` / `DeleteModel` still referenced by the typed graph | | `leaked_seam` | A view queries a model past a query module that already exists in the same context | -| `tests_bypass_interface` | Tests hit serializer/view internals while the published route or page seam is untested | - -Waivers live under `waivers:` in the same file. Reviewers are the `owners` of the bounded contexts in the impact subgraph. +| `tests_bypass_interface` | Tests hit internals while the published route or page seam is untested | -Review also scores **depth** on the impact path — the same vocabulary as a deep-module design pass: **module**, **interface**, **seam**, **leverage**, **locality**. A module is deep when a lot of behaviour sits behind a small interface. The **deletion test** asks whether removing a module concentrates complexity or just moves it. The **interface is the test surface**. Architecture is the survey (deepening opportunities ranked Strong / Worth exploring / Speculative); review then scopes those candidates to the git range. +Waivers live under `waivers:`. Reviewers are the `owners` of bounded contexts on the impact subgraph. -Impact walk skips permission/app/context hubs, does not climb `renders` into the App shell, and does not follow cross-context `relates_to` (an Invoice FK to UserProfile does not pull identity into a billing review). +Review also scores **depth** on the impact path (module, interface, seam, leverage, locality) and a **churn & coupling** slice of git history scoped to impact files. -Review also folds in a **churn & coupling** slice of git history (CodeScene-style hotspots, bus factor, temporal coupling, cyclomatic complexity on the changed functions) scoped to the same impact files — not a whole-repo hotspot map. +Impact walk skips permission/app/context hubs, does not climb `renders` into the App shell, and does not follow cross-context `relates_to`. ## How confidence is scored @@ -337,14 +319,12 @@ Line coverage on changed files is the wrong metric. Loadpath scores the **impact | Signal | High | Low | | --- | --- | --- | -| Tests | Sinks in the radius are hit by tests that still reach the changed symbol | Serializer changed, tests only on the view happy path (past the published seam) | +| Tests | Sinks in the radius are hit by tests that still reach the changed symbol | Serializer changed, tests only on the view happy path | | Contract | OpenAPI/client types track the serializer | React path/Zod field still old | | Architecture | No new cross-context edges; published seams hold | `crosses_context` or a leaked queryset seam | | Graph | Resolved edges | Many inferred/dynamic edges | -`high` / `medium` / `low` plus three reasons. Isolated leaf UI with green tests and no rule hits is labeled `loadpath:low-risk`. The same PR/range stores a **confidence trend** so a second review on that range can say whether confidence rose, dropped, or the sink count moved. - -Auth is a first-class load path: permission_classes, get_queryset object scope, and websocket routes without a gate. Untested seams get **suggested tests** (pytest / RTL / GraphQL / Channels / HTMX). Contract diffs are labeled `additive`, `breaking`, or `drift`. +`high` / `medium` / `low` plus three reasons. Isolated leaf UI with green tests and no rule hits is `loadpath:low-risk`. Auth is first-class (permission_classes, get_queryset, ungated websockets). Contract diffs are `additive`, `breaking`, or `drift`. ## Tests @@ -356,30 +336,20 @@ node --test desktop/*.test.mjs | Suite | What it covers | | --- | --- | -| `tests/unit/` | Django/React extractors, architecture rules, depth/seam survey, stitch, overlays (GraphQL/Channels/HTMX/FastAPI), SCM/AI providers | -| `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage; reviewers `billing-team` | -| `tests/e2e/test_cli_review.py` | `loadpath index` / `architecture` / `review` markdown, JSON, HTML | -| `tests/e2e/test_api_flow.py` | health, index, architecture, review-from-index, graph, settings, GitHub + Bitbucket PR list | -| `tests/e2e/test_mcp_oauth.py` | OAuth metadata/DCR/PKCE, consent, CIMD, MCP `review` stays on the billing load path | -| `tests/e2e/test_index_architecture_flow.py` | index snapshot, review without index, review walking an existing graph | -| `tests/e2e/test_brokers_and_django.py` | Celery + Dramatiq sinks, actor-only PR, non-idempotent Dramatiq warning, destructive migration, cross-context blocker, boot overlay, management commands, beat/canvas | -| `tests/e2e/test_ui_screenshots.py` | Playwright screenshots (tmpdir by default; set `LOADPATH_SCREENSHOT_DIR=docs/screenshots` to regenerate README assets) | -| `desktop/*.test.mjs` | Electron sidecar command, health-wait, and external-URL allowlist | - -To regenerate the README screenshots: +| `tests/unit/` | Extractors, rules, stitch, SCM/AI providers, loopback API | +| `tests/integration/test_review_vertical_slice.py` | Serializer field change reaches InvoicePage/Zod, not MePage | +| `tests/e2e/test_cli_review.py` | `index` / `architecture` / `review` markdown, JSON, HTML | +| `tests/e2e/test_api_flow.py` | health, index, architecture, review, graph, settings, PRs | +| `tests/e2e/test_mcp_oauth.py` | OAuth metadata/DCR/PKCE, consent, MCP `review` | +| `tests/e2e/test_ui_screenshots.py` | Playwright screenshots (`LOADPATH_SCREENSHOT_DIR=docs/screenshots` to regenerate) | +| `desktop/*.test.mjs` | Electron sidecar, health-wait, external-URL allowlist | -```bash -LOADPATH_SCREENSHOT_DIR=docs/screenshots python -m pytest tests/e2e/test_ui_screenshots.py -``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for screenshot regeneration, Vite, and the editor extension. ## Demo fixture -[`fixtures/demo_monorepo`](fixtures/demo_monorepo) is a billing/identity split: DRF ViewSet, FBV, Ninja ledger route, FastAPI sidecar gateway, Strawberry + Graphene schema, Channels consumer, Django template + HTMX board, cache keys / feature flags / `on_commit`, Celery tasks + beat + canvas, Dramatiq actor + GenericActor, management command, signal, FK string ref, React InvoicePage/Zod + a `gql` document. - -## What this is not - -Not CodeRabbit (comments without a closed impact set). Not a CodeScene clone (we do not replace its hotspot maps; we only score churn/coupling on the load path). Not django-orm-lens (we do not boot an ER explorer; we reuse its N+1 / cascade / blast-radius heuristics inside the typed graph). Not a generic SCIP call graph. The product is review as load-path inspection. +[`fixtures/demo_monorepo`](fixtures/demo_monorepo) is a billing/identity split: DRF ViewSet, FBV, Ninja, FastAPI sidecar, Strawberry + Graphene, Channels, HTMX, Celery + Dramatiq, React InvoicePage/Zod. ## License -MIT. See [LICENSE](LICENSE). Vulnerability reports: [SECURITY.md](SECURITY.md). +MIT. See [LICENSE](LICENSE). diff --git a/SECURITY.md b/SECURITY.md index 7709706..64d1547 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,7 +1,30 @@ # Security -Please report vulnerabilities privately to the repository owner (`Modsofthenation` on GitHub). Do not open a public issue for unreleased security problems. +Loadpath is a **local** tool. Tokens, OAuth state, and architecture indexes stay on the machine that runs it (`~/.loadpath/`, mode `0700` / files `0600`). Treat that host as trusted. -Once this repository is public, enable [GitHub private vulnerability reporting](https://docs.github.com/code-security/security-advisories/working-with-repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) so researchers can use Security Advisories. The `/security/advisories/new` form 404s until that setting is on. +## Report a vulnerability -Tokens and OAuth state live on the machine that runs Loadpath (`~/.loadpath/`); treat that host as trusted. SCM sign-in, disconnect, and `/api/scm/repos` only accept the local Loadpath UI (loopback Origin/Host), so a tunneled MCP server does not list private repositories. +Please report vulnerabilities **privately**. Do not open a public issue for unreleased security problems. + +1. Enable [GitHub private vulnerability reporting](https://docs.github.com/code-security/security-advisories/working-with-repository-security-advisories/configuring-private-vulnerability-reporting-for-a-repository) on this repository (required before `/security/advisories/new` works). +2. Use **Security → Report a vulnerability**, or contact the repository owner (`Modsofthenation` on GitHub). + +## What is in scope + +- Token or OAuth secret leakage from the local API, UI, MCP server, or GitHub Action +- Cross-site request forgery against `loadpath serve` while it is running +- Path traversal or unexpected filesystem writes from `repo_path` / the repo explorer +- Remote use of stored SCM or AI credentials when the process is bound or tunneled + +## What is out of scope + +- Findings that require physical or local-user access to `~/.loadpath/` +- Issues that only apply if you set `LOADPATH_OAUTH_AUTO_APPROVE=1` (that flag skips consent; never use it outside tests) +- Unsigned desktop installers (macOS Gatekeeper) — tracked as packaging, not a vulnerability + +## Hardening notes + +- `loadpath serve` defaults to `127.0.0.1`. The HTTP UI (`/api/*` except `/api/health`) refuses non-loopback `Origin` / `Host`. +- SCM sign-in, settings, filesystem browse, review, and PR comments are local-UI only. A tunneled MCP server does not expose those routes. +- MCP over HTTP uses OAuth 2.1 (PKCE, consent). Prefer `--oauth-pin` when `--public-url` is set. +- The GitHub Action interpolates inputs through environment variables, not shell expansion. diff --git a/editors/vscode/README.md b/editors/vscode/README.md index 254fe80..4b6f2d0 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -1,6 +1,6 @@ # Loadpath editor gutter -Marks files on the current Loadpath walk while `loadpath serve` is running on this machine. +Marks files on the current Loadpath walk while `loadpath serve` is running on this machine (`http://127.0.0.1:7345` by default). The editor talks to the local `/api/marks` route; a tunneled MCP URL is not this feed. ``` code --install-extension /path/to/PR-Reviewer/editors/vscode diff --git a/pyproject.toml b/pyproject.toml index 98afc15..3c74b05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,16 +5,18 @@ build-backend = "setuptools.build_meta" [project] name = "loadpath" version = "0.1.0" -description = "Architecture-typed impact graphs for Django + React pull requests." +description = "Architecture-typed impact graphs for Django + React pull requests. Local load-path review, not a hunk-comment bot." readme = "README.md" requires-python = ">=3.12" license = { text = "MIT" } authors = [{ name = "Loadpath" }] keywords = ["django", "react", "code-review", "architecture", "mcp"] classifiers = [ + "Development Status :: 4 - Beta", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3.12", "Topic :: Software Development :: Quality Assurance", + "Intended Audience :: Developers", ] dependencies = [ "fastapi>=0.115.0", @@ -42,6 +44,8 @@ dev = [ Homepage = "https://github.com/Modsofthenation/PR-Reviewer" Repository = "https://github.com/Modsofthenation/PR-Reviewer" Issues = "https://github.com/Modsofthenation/PR-Reviewer/issues" +Documentation = "https://github.com/Modsofthenation/PR-Reviewer#readme" +"Bug Tracker" = "https://github.com/Modsofthenation/PR-Reviewer/issues" [project.scripts] loadpath = "loadpath.cli:app"