Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions src/loadpath/architecture/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
NodeType.SERIALIZER.value,
NodeType.FORM.value,
NodeType.MODEL.value,
NodeType.SERVICE.value,
NodeType.PERMISSION.value,
NodeType.TASK.value,
NodeType.MANAGEMENT_COMMAND.value,
NodeType.SIGNAL.value,
Expand Down
69 changes: 61 additions & 8 deletions src/loadpath/extractors/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,27 @@ def _has_base(node: ast.ClassDef, names: set[str]) -> bool:
return any((b.split(".")[-1] in names) for b in _bases(node))


def _is_test_path(rel: str) -> bool:
path = Path(rel)
return path.name.startswith("test") or "/tests/" in f"/{rel}/" or path.name == "tests.py"


def _is_dataclass(node: ast.ClassDef) -> bool:
return any(name.split(".")[-1] == "dataclass" for name in _decorator_names(node))


def _is_permission_class(node: ast.ClassDef) -> bool:
if node.name.startswith("Test"):
return False
if node.name.endswith("Permission"):
return True
return any(
part in {"BasePermission", "BasePermissionMetaclass", "TaigaResourcePermission", "ResourcePermission"}
for base in _bases(node)
for part in base.split(".")
)


def _decorator_names(node: ast.FunctionDef | ast.ClassDef | ast.AsyncFunctionDef) -> list[str]:
out = []
for dec in node.decorator_list:
Expand Down Expand Up @@ -444,8 +465,12 @@ def visit_ClassDef(self, node: ast.ClassDef) -> None:
self._admin(node)
elif any(x.endswith("Config") for x in _bases(node)) or node.name.endswith("Config"):
self._app_config(node)
elif _is_permission_class(node):
self._permission_class(node)
elif "Service" in node.name or "UseCase" in node.name:
self._service_class(node)
elif _is_dataclass(node) and not _is_test_path(self.rel_path):
self._service_class(node)
self.generic_visit(node)
self.class_stack.pop()

Expand Down Expand Up @@ -742,9 +767,16 @@ def _view(self, node: ast.ClassDef) -> None:
fs_q = fs if "." in fs and not fs.startswith("filter") else f"{self.app}.{fs.split('.')[-1]}"
self.add_edge(view.id, node_id(NodeType.FORM, fs_q), EdgeType.CALLS, confidence=0.9)
for perm in permissions:
pid = node_id(NodeType.PERMISSION, perm)
ident = self._permission_identity(perm)
pid = node_id(NodeType.PERMISSION, ident)
self.graph.nodes.append(
Node(id=pid, type=NodeType.PERMISSION, name=perm, qualified_name=perm, extra={"from_view": qname})
Node(
id=pid,
type=NodeType.PERMISSION,
name=perm.split(".")[-1],
qualified_name=ident,
extra={"from_view": qname},
)
)
self.add_edge(view.id, pid, EdgeType.HAS_PERMISSION)
for throttle in extra.get("throttles") or []:
Expand Down Expand Up @@ -796,6 +828,23 @@ def _admin(self, node: ast.ClassDef) -> None:
qname = f"{self.app}.{node.name}"
self.add_node(NodeType.ADMIN, node.name, qname, node.lineno, _with_doc({"app": self.app}, node))

def _permission_identity(self, perm: str) -> str:
"""Local *Permission classes share an id with view.permission_classes."""
short = perm.split(".")[-1]
if short.endswith("Permission"):
return f"{self.app}.{short}"
return short

def _permission_class(self, node: ast.ClassDef) -> None:
qname = self._permission_identity(node.name)
self.add_node(
NodeType.PERMISSION,
node.name,
qname,
node.lineno,
_with_doc({"app": self.app, "permission_class": True}, node),
)

def _service_class(self, node: ast.ClassDef) -> None:
qname = f"{self.app}.{node.name}"
self.add_node(NodeType.SERVICE, node.name, qname, node.lineno, _with_doc({"app": self.app}, node))
Expand Down Expand Up @@ -1570,11 +1619,7 @@ def _maybe_command(self, node: ast.FunctionDef) -> None:
self.add_node(NodeType.MANAGEMENT_COMMAND, cmd, f"{self.app}.{cmd}", node.lineno, {"app": self.app})

def _maybe_test(self, node: ast.FunctionDef) -> None:
is_test_file = (
Path(self.rel_path).name.startswith("test")
or "/tests/" in f"/{self.rel_path}/"
or Path(self.rel_path).name == "tests.py"
)
is_test_file = _is_test_path(self.rel_path)
if not is_test_file:
return
if not (node.name.startswith("test_") or node.name.startswith("test")):
Expand All @@ -1592,7 +1637,15 @@ def _maybe_test(self, node: ast.FunctionDef) -> None:
# crude: referenced class names in the test become tested_by
for child in ast.walk(node):
if isinstance(child, ast.Name) and child.id[:1].isupper():
for ntype in (NodeType.SERIALIZER, NodeType.FORM, NodeType.VIEW, NodeType.MODEL, NodeType.SERVICE, NodeType.RECEIVER):
for ntype in (
NodeType.SERIALIZER,
NodeType.FORM,
NodeType.VIEW,
NodeType.MODEL,
NodeType.SERVICE,
NodeType.RECEIVER,
NodeType.PERMISSION,
):
self.add_edge(
node_id(ntype, f"{self.app}.{child.id}"),
test.id,
Expand Down
2 changes: 1 addition & 1 deletion src/loadpath/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
PY_SKIP = {"migrations"} # still extract migrations, just not skip
INDEX_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx", ".html", ".htm", ".graphql", ".gql"}
# Bump when extractor/stitch node identity changes so incremental indexes rebuild.
INDEX_REVISION = "14"
INDEX_REVISION = "16"
_UPSERT_BATCH = 25

ProgressCallback = Callable[[dict[str, Any]], None]
Expand Down
3 changes: 1 addition & 2 deletions src/loadpath/review/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def classify_change(impact_nodes: list[dict], findings: list, seeds: list[dict]
kinds.add(ChangeKind.CROSS_CONTEXT.value)
if NodeType.SERVICE.value in types and ChangeKind.PUBLIC_CONTRACT.value not in kinds:
kinds.add(ChangeKind.INTERNAL_SERVICE.value)
ui_only = types <= {
ui_only = bool(types) and types <= {
NodeType.COMPONENT.value,
NodeType.PAGE.value,
NodeType.REACT_ROUTE.value,
Expand Down Expand Up @@ -410,7 +410,6 @@ def run_review(
for f in findings
if (f.node_id and f.node_id in impact_ids)
or (f.file_path and f.file_path in impact_files)
or not impact_ids
]
residuals = collect_residuals(store, impact_nodes, diff)
evolution = analyze_evolution(repo_root, diff, impact_nodes, config)
Expand Down
6 changes: 6 additions & 0 deletions src/loadpath/review/experience.py
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,12 @@ def checklist(review: dict[str, Any]) -> list[dict[str, Any]]:
"action": "none",
},
)
if "nodes" in review:
ids = {n.get("id") for n in (review.get("nodes") or []) if n.get("id")}
for item in items:
nid = item.get("node_id")
if nid and nid not in ids:
item["node_id"] = None
return items


Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions src/loadpath/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<script type="module" crossorigin src="./assets/index-BuTg-qls.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CdW5Vb1S.css">
<script type="module" crossorigin src="./assets/index-Cj5VBWfS.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-eMEYJf2U.css">
</head>
<body>
<div id="root"></div>
Expand Down
10 changes: 2 additions & 8 deletions tests/e2e/test_ui_flows.py
Original file line number Diff line number Diff line change
Expand Up @@ -206,8 +206,6 @@ def test_ui_index_review_graph_copy_and_workspace(live_app, browser_page):

@pytest.mark.playwright
def test_ui_index_polls_progress_endpoint(live_app, browser_page):
import time

base_url, repo = live_app
page = browser_page
page.goto(base_url, wait_until="networkidle")
Expand All @@ -223,10 +221,6 @@ def on_route(route):
progress_hits.append(url)
route.continue_()
return
if req.method == "POST" and url.rstrip("/").endswith("/api/index"):
time.sleep(1.2)
route.continue_()
return
route.continue_()

page.route("**/api/**", on_route)
Expand All @@ -239,9 +233,9 @@ def on_route(route):
page.wait_for_function(
"""() => {
const t = document.querySelector('.rail-foot .muted')?.textContent || '';
return /Extract|Scan|Stitch|Indexed|Boot|Hashed/.test(t);
return /Extract|Scan|Stitch|Indexed|Boot|Hashed|Indexing/.test(t);
}""",
timeout=10_000,
timeout=20_000,
)
page.screenshot(path="/opt/cursor/artifacts/index_progress_bar.png")
page.get_by_test_id("architecture-brief").locator(".level").wait_for(timeout=15_000)
Expand Down
72 changes: 72 additions & 0 deletions tests/unit/test_django_extractors.py
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,77 @@ def test_dead_serializer_dict_does_not_resolve_get_serializer_class():
assert any("get_serializer_class" in r for r in g.residuals)


def test_extracts_permission_class_and_dataclass_service():
source = (
"from dataclasses import dataclass\n"
"from rest_framework.permissions import BasePermission\n"
"\n"
"class InvoicePermission(BasePermission):\n"
" def has_permission(self, request, view):\n"
" return True\n"
"\n"
"@dataclass(frozen=True)\n"
"class ViewerAccess:\n"
" privileged: bool = False\n"
"\n"
"def test_viewer_access():\n"
" ViewerAccess(privileged=True)\n"
" InvoicePermission()\n"
)
g = extract_django_file("backend/billing/access.py", source, _cfg())
assert any(n.type is NodeType.PERMISSION and n.name == "InvoicePermission" for n in g.nodes)
assert any(n.type is NodeType.SERVICE and n.name == "ViewerAccess" for n in g.nodes)
tests = extract_django_file(
"backend/billing/tests/test_access.py",
"from billing.access import ViewerAccess, InvoicePermission\n"
"def test_viewer_access():\n"
" ViewerAccess()\n"
" InvoicePermission()\n",
_cfg(),
)
dsts = {e.dst for e in tests.edges}
srcs = {e.src for e in tests.edges}
assert any("ViewerAccess" in s for s in srcs)
assert any("InvoicePermission" in s for s in srcs)
assert any("test_viewer_access" in d for d in dsts)


def test_view_permission_classes_share_id_with_permission_class():
perm_file = extract_django_file(
"backend/billing/permissions.py",
"from rest_framework.permissions import BasePermission\n"
"class InvoicePermission(BasePermission):\n"
" def has_permission(self, request, view):\n"
" return True\n",
_cfg(),
)
view_file = extract_django_file(
"backend/billing/views.py",
"from rest_framework.viewsets import ModelViewSet\n"
"class InvoiceViewSet(ModelViewSet):\n"
" permission_classes = [InvoicePermission, IsAuthenticated]\n",
_cfg(),
)
class_ids = {n.id for n in perm_file.nodes if n.type is NodeType.PERMISSION}
view_ids = {n.id for n in view_file.nodes if n.type is NodeType.PERMISSION}
assert "django.permission:billing.InvoicePermission" in class_ids
assert "django.permission:billing.InvoicePermission" in view_ids
assert "django.permission:IsAuthenticated" in view_ids


def test_dataclass_in_tests_is_not_a_service():
source = (
"from dataclasses import dataclass\n"
"@dataclass\n"
"class FixtureRow:\n"
" name: str\n"
"def test_row():\n"
" FixtureRow('x')\n"
)
g = extract_django_file("backend/billing/tests/test_rows.py", source, _cfg())
assert not any(n.type is NodeType.SERVICE and n.name == "FixtureRow" for n in g.nodes)


def test_marshmallow_schema_is_not_ninja_when_router_imported():
source = (
"from ninja import Router\n"
Expand All @@ -771,3 +842,4 @@ def test_marshmallow_schema_is_not_ninja_when_router_imported():
assert not any(n.extra.get("ninja_schema") for n in g.nodes)



21 changes: 21 additions & 0 deletions tests/unit/test_experience.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,27 @@ def test_checklist_todos_for_blocker_and_untested():
assert "it('renders')" in (test_item.get("body") or "")


def test_checklist_drops_node_ids_missing_from_the_graph():
items = checklist(
{
"nodes": [],
"confidence": {"level": "medium", "untested_sinks": []},
"findings": [
{
"rule": "queryset_nplusone",
"severity": "warning",
"message": "unrelated model",
"node_id": "django.model:order.AbstractOrder",
"waived": False,
}
],
"contract_break": {"kind": "none"},
}
)
finding = next(i for i in items if i["kind"] == "finding")
assert finding["node_id"] is None


def test_isolate_paths_keeps_only_source_to_sink():
nodes = [
{"id": "a", "type": "django.field", "name": "total"},
Expand Down
25 changes: 22 additions & 3 deletions tests/unit/test_review_features.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
from loadpath.review.auth import auth_path
from loadpath.review.contract import classify_contract_break
from loadpath.review.diff import DiffSet, FileDiff, git_diff
from loadpath.review.engine import run_review
from loadpath.review.engine import classify_change, run_review
from loadpath.review.gate import FAIL_ON_CHOICES, gate_result, write_github_output
from loadpath.review.suggested_tests import suggested_tests
from loadpath.review.trend import confidence_trend
from loadpath.review.whatif import simulate_node
from loadpath.types import ContractBreakKind
from loadpath.types import ChangeKind, ContractBreakKind

from tests.conftest import copy_fixture, git_init_with_main, prepare_review_repo
from tests.conftest import copy_fixture, git_commit_all, git_init_with_main, prepare_review_repo


def test_contract_break_required_field_is_breaking():
Expand Down Expand Up @@ -216,3 +216,22 @@ def test_confidence_trend_compares_same_range(tmp_path):
note = confidence_trend(store, base=first["base"] if "base" in first else None, head=None)
store.close()
assert note["note"]


def test_empty_impact_is_not_leaf_ui():
assert ChangeKind.LEAF_UI.value not in classify_change([], [])
assert classify_change([], []) == [ChangeKind.INTERNAL_SERVICE.value]


def test_docs_only_review_does_not_attach_architecture_findings(tmp_path):
repo = prepare_review_repo(tmp_path)
(repo / "README.md").write_text("# docs only\n", encoding="utf-8")
git_commit_all(repo, "docs")
review = run_review(repo, base="HEAD~1", head="HEAD")
assert review["nodes"] == []
assert review["edges"] == []
assert "leaf_ui" not in review["change_kinds"]
assert review["findings"] == []
for item in review["checklist"]:
assert not item.get("node_id")

2 changes: 1 addition & 1 deletion ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1445,7 +1445,7 @@ export function App() {
Tests
</button>
</div>
{graphNodes.length ? (
{graphNodes.length || (graphMode === "review" && review) || (architecture?.indexed && !(graphLoading || architecture?.graph_pending)) ? (
<ImpactGraph
nodes={graphNodes}
edges={graphEdges}
Expand Down
6 changes: 6 additions & 0 deletions ui/src/ImpactGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,4 +62,10 @@ describe("toReactFlowElements", () => {
expect(rfEdges.find((e) => e.id === "ok")?.label).toBe("uses serializer");
expect(rfEdges.find((e) => e.id === "other")?.label).toBeUndefined();
});

it("layouts an empty walk without nodes or edges", () => {
const { rfNodes, rfEdges } = toReactFlowElements([], []);
expect(rfNodes).toEqual([]);
expect(rfEdges).toEqual([]);
});
});
10 changes: 9 additions & 1 deletion ui/src/ImpactGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -738,7 +738,15 @@ export function ImpactGraph({
</span>
</div>
<div className="graph-stage">
{view === "3d" ? (
{nodes.length === 0 ? (
<div className="empty graph-walk-empty" data-testid="graph-walk-empty">
<h2>No typed nodes on this walk</h2>
<p>
This range did not hit models, views, routes, or React pages Loadpath extracts. Open the
architecture map for the indexed graph.
</p>
</div>
) : view === "3d" ? (
<div className="graph-3d" data-testid="graph-3d">
<p className="graph-3d-hint">
Architecture layers are stacked in depth (Django → stitch → React). Drag to orbit, scroll to
Expand Down
Loading
Loading