From 21196e13ec08d8777d38afa76e10877f38802b46 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:05:35 +0000 Subject: [PATCH 1/6] Give empty Django URL mounts a readable, unique graph name. path("", include(...)) and index views were stored as blank route nodes, so the inspector and sink list showed empty labels. Label those mounts from the include target or url name, keep ids unique, and stop visiting urlpatterns twice. Co-authored-by: zord.lack.net --- src/loadpath/extractors/django.py | 34 ++++++++++++++++++++++------ src/loadpath/extractors/react.py | 4 ++-- tests/unit/test_django_extractors.py | 22 ++++++++++++++++++ 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/loadpath/extractors/django.py b/src/loadpath/extractors/django.py index a200bf1..2ce10da 100644 --- a/src/loadpath/extractors/django.py +++ b/src/loadpath/extractors/django.py @@ -314,12 +314,7 @@ def visit_Call(self, node: ast.Call) -> None: self.generic_visit(node) def visit_Assign(self, node: ast.Assign) -> None: - # urlpatterns = [...] for target in node.targets: - if isinstance(target, ast.Name) and target.id == "urlpatterns" and isinstance(node.value, (ast.List, ast.Tuple)): - for elt in node.value.elts: - if isinstance(elt, ast.Call): - self.visit_Call(elt) if isinstance(target, ast.Name) and target.id in {"CELERY_BEAT_SCHEDULE", "beat_schedule"}: self._beat_schedule(node.value) self.generic_visit(node) @@ -890,6 +885,30 @@ def _maybe_test(self, node: ast.FunctionDef) -> None: confidence=0.7, ) + def _include_target(self, call: ast.Call) -> str | None: + if not call.args: + return None + arg0 = call.args[0] + hit = _const_str(arg0) or _name(arg0) + if hit: + return hit + if isinstance(arg0, (ast.Tuple, ast.List)) and arg0.elts: + return _const_str(arg0.elts[0]) or _name(arg0.elts[0]) + return None + + def _route_identity( + self, route: str, include_mod: str | None, name: str | None, lineno: int + ) -> tuple[str, str]: + """Empty `path("", …)` must still show a label and a unique id.""" + stamp = f"{Path(self.rel_path).name}:{lineno}" + if route: + return route, f"{self.app}:{route}" + if include_mod: + return f"include:{include_mod}", f"{self.app}:include:{include_mod}:{stamp}" + if name: + return name, f"{self.app}:{name}" + return "/", f"{self.app}:/:{stamp}" + def _url_path(self, node: ast.Call) -> None: if not node.args: return @@ -917,7 +936,7 @@ def _url_path(self, node: ast.Call) -> None: if isinstance(view_expr, ast.Call): fn = _name(view_expr.func) or "" if fn.split(".")[-1] == "include": - include_mod = _const_str(view_expr.args[0]) if view_expr.args else _name(view_expr.args[0] if view_expr.args else None) + include_mod = self._include_target(view_expr) view_name = None extra = { "app": self.app, @@ -926,7 +945,8 @@ def _url_path(self, node: ast.Call) -> None: "view": view_name, "include": include_mod, } - route_node = self.add_node(NodeType.ROUTE, f"{route}", f"{self.app}:{route}", node.lineno, extra) + display, qname = self._route_identity(route, include_mod, name, node.lineno) + route_node = self.add_node(NodeType.ROUTE, display, qname, node.lineno, extra) if name: un = self.add_node(NodeType.URL_NAME, name, name, node.lineno, extra) self.add_edge(route_node.id, un.id, EdgeType.BELONGS_TO) diff --git a/src/loadpath/extractors/react.py b/src/loadpath/extractors/react.py index 9e42d7e..114009a 100644 --- a/src/loadpath/extractors/react.py +++ b/src/loadpath/extractors/react.py @@ -287,7 +287,7 @@ def edge(src: str, dst: str, etype: EdgeType, confidence: float = 1.0, extra: di el_m = ROUTE_ATTR_ELEMENT.search(attrs) if not path_m: continue - rpath = path_m.group(1) + rpath = path_m.group(1) or "/" line = source[: m.start()].count("\n") + 1 page_name = el_m.group(1) if el_m else rpath rn = add(NodeType.REACT_ROUTE, rpath, f"react.route:{rpath}", line, {"element": page_name}) @@ -298,7 +298,7 @@ def edge(src: str, dst: str, etype: EdgeType, confidence: float = 1.0, extra: di edge(rn.id, node_id(NodeType.COMPONENT, f"{feat}.{page_name}"), EdgeType.RENDERS) for m in PATH_OBJ_RE.finditer(source): - rpath, page_name = m.group(1), m.group(2) + rpath, page_name = m.group(1) or "/", m.group(2) line = source[: m.start()].count("\n") + 1 rn = add(NodeType.REACT_ROUTE, rpath, f"react.route:{rpath}", line, {"element": page_name}) edge(rn.id, node_id(NodeType.PAGE, f"{feature or 'app'}.{page_name}"), EdgeType.PUBLISHES_ROUTE) diff --git a/tests/unit/test_django_extractors.py b/tests/unit/test_django_extractors.py index 854470f..733e123 100644 --- a/tests/unit/test_django_extractors.py +++ b/tests/unit/test_django_extractors.py @@ -48,6 +48,28 @@ def test_extracts_router_and_path_routes(): routes = [n for n in g.nodes if n.type is NodeType.ROUTE] assert routes assert any(e.type.value == "publishes_route" for e in g.edges) + assert all(n.name for n in routes) + + +def test_empty_include_route_gets_a_readable_name(): + source = ( + "from django.urls import include, path\n" + "urlpatterns = [\n" + " path('', include('custom_auth.mfa.urls')),\n" + " path('', include(ffadmin_user_router.urls)),\n" + " path('', InvoiceView.as_view(), name='index'),\n" + "]\n" + ) + g = extract_django_file("api/custom_auth/urls.py", source, _cfg()) + routes = [n for n in g.nodes if n.type is NodeType.ROUTE] + names = {n.name for n in routes} + assert "include:custom_auth.mfa.urls" in names + assert "include:ffadmin_user_router.urls" in names + assert "index" in names + assert all(n.name for n in routes) + ids = [n.id for n in routes] + assert len(ids) == len(set(ids)) + assert len(routes) == 3 def test_extracts_signal_receiver(): From 0348ee67aad54d14d281c2e780276c30293bbd33 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:05:40 +0000 Subject: [PATCH 2/6] Fall back when git three-dot diff has no merge base. Shallow PR checkouts often cannot compute origin/main...HEAD, which crashed review. Try a two-dot range next, and return an empty diff instead of raising if git still fails. Co-authored-by: zord.lack.net --- src/loadpath/review/diff.py | 56 +++++++++++++++++++++++++----------- tests/unit/test_workspace.py | 17 +++++++++++ 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/loadpath/review/diff.py b/src/loadpath/review/diff.py index 7c9d2d3..bbfe6f1 100644 --- a/src/loadpath/review/diff.py +++ b/src/loadpath/review/diff.py @@ -51,23 +51,45 @@ def git_diff( three_dot: bool = True, ) -> DiffSet: repo_root = repo_root.resolve() - spec = _range_args(base, head, three_dot) - numstat = subprocess.check_output( - ["git", "-C", str(repo_root), "diff", "--numstat", "-M", *spec], - text=True, - stderr=subprocess.DEVNULL, - ) - namestat = subprocess.check_output( - ["git", "-C", str(repo_root), "diff", "--name-status", "-M", *spec], - text=True, - stderr=subprocess.DEVNULL, - ) - patch = subprocess.check_output( - ["git", "-C", str(repo_root), "diff", "-U3", *spec], - text=True, - stderr=subprocess.DEVNULL, - errors="replace", - ) + specs: list[list[str]] = [] + if head and three_dot: + specs.append(_range_args(base, head, True)) + if head: + two = _range_args(base, head, False) + if two not in specs: + specs.append(two) + if not specs: + specs.append(_range_args(base, head, three_dot)) + + last_error: subprocess.CalledProcessError | None = None + numstat = namestat = patch = "" + used = specs[0] + for spec in specs: + try: + numstat = subprocess.check_output( + ["git", "-C", str(repo_root), "diff", "--numstat", "-M", *spec], + text=True, + stderr=subprocess.DEVNULL, + ) + namestat = subprocess.check_output( + ["git", "-C", str(repo_root), "diff", "--name-status", "-M", *spec], + text=True, + stderr=subprocess.DEVNULL, + ) + patch = subprocess.check_output( + ["git", "-C", str(repo_root), "diff", "-U3", *spec], + text=True, + stderr=subprocess.DEVNULL, + errors="replace", + ) + used = spec + last_error = None + break + except subprocess.CalledProcessError as exc: + last_error = exc + continue + if last_error is not None: + return DiffSet(files=[], base=base, head=head or "WORKTREE") patches = _split_patches(patch) added_map: dict[str, tuple[int, int]] = {} diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py index 38169b3..d463d8d 100644 --- a/tests/unit/test_workspace.py +++ b/tests/unit/test_workspace.py @@ -22,6 +22,23 @@ def test_merge_base_and_three_dot_range(tmp_path: Path): assert any(f.path == "b.txt" for f in diff.files) +def test_git_diff_falls_back_when_three_dot_has_no_merge_base(tmp_path: Path): + repo = tmp_path / "r" + repo.mkdir() + (repo / "a.txt").write_text("one\n") + git_init_with_main(repo) + run = lambda *args: __import__("subprocess").check_call( + ["git", "-C", str(repo), *args], stdout=__import__("subprocess").DEVNULL + ) + run("checkout", "--orphan", "pr") + run("rm", "-rf", ".") + (repo / "b.txt").write_text("pr\n") + git_commit_all(repo, "pr") + diff = git_diff(repo, "main", "HEAD", three_dot=True) + assert isinstance(diff.files, list) + assert any(f.path == "b.txt" for f in diff.files) + + def test_dirty_paths_include_uncommitted(tmp_path: Path): repo = tmp_path / "r" repo.mkdir() From 6f8e0e50e469fba7ad3daf44c61420541246808f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:05:40 +0000 Subject: [PATCH 3/6] Stop treating docs sites and Python src/ as the React root. Flagsmith and Zulip were indexed from Docusaurus/Starlight trees, and Oscar's src/ package was picked as frontend. Prefer real frontend dirs, skip docs-like trees while walking sources, and leave a Python src/ layout on the default React path. Co-authored-by: zord.lack.net --- src/loadpath/detect.py | 61 ++++++++++++++++++++++++++++++++++++--- src/loadpath/index.py | 11 ++++++- tests/unit/test_detect.py | 21 ++++++++++++++ 3 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/loadpath/detect.py b/src/loadpath/detect.py index 92b95a2..c83c9e9 100644 --- a/src/loadpath/detect.py +++ b/src/loadpath/detect.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from pathlib import Path from typing import Any @@ -21,6 +22,12 @@ ".mypy_cache", ".pytest_cache", "site-packages", + "docs", + "documentation", + "website", + "docusaurus", + "storybook", + "starlight_help", } TESTISH_PARTS = {"test", "tests", "testing"} @@ -154,16 +161,62 @@ def _detect_django_root(repo_root: Path) -> str: return "backend" +PREFERRED_REACT_ROOTS = ( + "frontend/src", + "frontend", + "src-ui/src", + "web/src", + "client/src", + "ui/src", +) + +SKIP_REACT_PARTS = { + "docs", + "documentation", + "website", + "docusaurus", + "storybook", + "starlight_help", + "e2e", + "cypress", +} + + +def _package_has_react(pkg: Path) -> bool: + try: + data = json.loads(pkg.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError, UnicodeDecodeError): + return False + deps = {**(data.get("dependencies") or {}), **(data.get("devDependencies") or {})} + return "react" in deps or "react-dom" in deps + + def _detect_react_root(repo_root: Path) -> str: + for candidate in PREFERRED_REACT_ROOTS: + path = repo_root / candidate + if path.is_dir(): + return candidate + + scored: list[tuple[int, str]] = [] for pkg in repo_root.rglob("package.json"): if _skip(pkg): continue + if any(part in SKIP_REACT_PARTS for part in pkg.parts): + continue + if not _package_has_react(pkg): + continue src = pkg.parent / "src" + root = src if src.is_dir() else pkg.parent + rel = root.relative_to(repo_root).as_posix() + score = 0 + if any(token in rel.split("/") for token in {"frontend", "web", "ui", "client", "src-ui"}): + score += 10 if src.is_dir(): - return src.relative_to(repo_root).as_posix() - for candidate in ("frontend/src", "web/src", "ui/src", "client/src", "src"): - if (repo_root / candidate).is_dir(): - return candidate + score += 5 + scored.append((score, rel)) + if scored: + scored.sort(key=lambda item: (-item[0], len(item[1]), item[1])) + return scored[0][1] return "frontend/src" diff --git a/src/loadpath/index.py b/src/loadpath/index.py index f68434e..a8d19b5 100644 --- a/src/loadpath/index.py +++ b/src/loadpath/index.py @@ -15,7 +15,7 @@ PY_SKIP = {"migrations"} # still extract migrations, just not skip INDEX_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx"} # Bump when extractor/stitch node identity changes so incremental indexes rebuild. -INDEX_REVISION = "4" +INDEX_REVISION = "5" def default_db_path(repo_root: Path) -> Path: @@ -35,6 +35,15 @@ def iter_source_files(repo_root: Path, config: LoadpathConfig) -> list[Path]: "build", ".mypy_cache", ".pytest_cache", + "docs", + "documentation", + "website", + "docusaurus", + "storybook", + "starlight_help", + "collected_static", + "staticfiles", + "locale", } for path in repo_root.rglob("*"): if not path.is_file() or path.suffix not in INDEX_EXTENSIONS: diff --git a/tests/unit/test_detect.py b/tests/unit/test_detect.py index 7c84949..aedb4df 100644 --- a/tests/unit/test_detect.py +++ b/tests/unit/test_detect.py @@ -43,6 +43,27 @@ def test_write_draft_creates_manifest(tmp_path: Path): assert "queryset_nplusone" in text +def test_detect_prefers_frontend_over_docs_site(tmp_path: Path): + (tmp_path / "api" / "billing").mkdir(parents=True) + (tmp_path / "api" / "billing" / "apps.py").write_text("class BillingConfig:\n pass\n") + (tmp_path / "docs" / "src").mkdir(parents=True) + (tmp_path / "docs" / "package.json").write_text('{"dependencies":{"react":"19.0.0"}}\n') + (tmp_path / "frontend" / "web").mkdir(parents=True) + (tmp_path / "frontend" / "package.json").write_text('{"dependencies":{"react":"19.0.0"}}\n') + layout = detect_layout(tmp_path) + assert layout["react_root"] == "frontend" + assert layout["django_root"] == "api" + + +def test_detect_does_not_treat_python_src_as_react_root(tmp_path: Path): + (tmp_path / "src" / "oscar").mkdir(parents=True) + (tmp_path / "src" / "oscar" / "apps.py").write_text("class OscarConfig:\n pass\n") + (tmp_path / "package.json").write_text("{}\n", encoding="utf-8") + layout = detect_layout(tmp_path) + assert layout["django_root"] == "src" + assert layout["react_root"] == "frontend/src" + + def test_detect_skips_nested_test_project_manage_py(tmp_path: Path): """Library repos (Wagtail) keep manage.py under a test project — index the package.""" pkg = tmp_path / "pack" / "contrib" / "redirects" From 2580e6cfc81ff6026ee85af4c041ece0f31b1c14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:14:24 +0000 Subject: [PATCH 4/6] Compose included routes from the real path, not the display label. Empty path("", include(...)) mounts were joined as include:module, so child URLs showed up as /include:zproject.tornado_urls/.... Use the declared pattern (including ""), strip regex anchors, and match geonode.base.urls to the base app instead of every geonode route. Co-authored-by: zord.lack.net --- src/loadpath/architecture/depth.py | 3 +- src/loadpath/index.py | 2 +- src/loadpath/review/engine.py | 3 +- src/loadpath/stitch/openapi.py | 57 +++++++++++++++++++---- tests/unit/test_index_and_stitch.py | 71 +++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 13 deletions(-) diff --git a/src/loadpath/architecture/depth.py b/src/loadpath/architecture/depth.py index 311de56..8187db7 100644 --- a/src/loadpath/architecture/depth.py +++ b/src/loadpath/architecture/depth.py @@ -13,6 +13,7 @@ from loadpath.architecture.rules import Finding from loadpath.config import LoadpathConfig from loadpath.graph.store import GraphStore +from loadpath.stitch.openapi import published_route from loadpath.types import EdgeType, NodeType, RuleSeverity DEPTH_RULES = ("leaked_seam", "tests_bypass_interface") @@ -245,7 +246,7 @@ def _tests_bypass_interface(store: GraphStore) -> list[Finding]: tested_behind = [nid for nid in behind if nid in tested_src] if not tested_behind: continue - seam_name = route.get("extra", {}).get("mounted_at") or route["name"] + seam_name = published_route(route) internals = [] for nid in tested_behind: node = views.get(nid) or serializers.get(nid) or pages.get(nid) or store.get_node(nid) diff --git a/src/loadpath/index.py b/src/loadpath/index.py index a8d19b5..ac4e877 100644 --- a/src/loadpath/index.py +++ b/src/loadpath/index.py @@ -15,7 +15,7 @@ PY_SKIP = {"migrations"} # still extract migrations, just not skip INDEX_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx"} # Bump when extractor/stitch node identity changes so incremental indexes rebuild. -INDEX_REVISION = "5" +INDEX_REVISION = "6" def default_db_path(repo_root: Path) -> Path: diff --git a/src/loadpath/review/engine.py b/src/loadpath/review/engine.py index 4d1e340..707a138 100644 --- a/src/loadpath/review/engine.py +++ b/src/loadpath/review/engine.py @@ -14,6 +14,7 @@ from loadpath.review.confidence import score_confidence from loadpath.review.diff import DiffSet, git_diff from loadpath.review.evolution import analyze_evolution +from loadpath.stitch.openapi import published_route from loadpath.workspace import git_dirty_paths, resolve_review_range from loadpath.types import ( ChangeKind, @@ -504,7 +505,7 @@ def _sink_summaries(nodes: list[dict], store: GraphStore) -> list[dict]: for n in nodes: if n["type"] in interesting: extra = n.get("extra") or {} - name = extra.get("mounted_at") or extra.get("full_path") or n["name"] + name = published_route(n) if n["type"] == NodeType.ROUTE.value else extra.get("mounted_at") or extra.get("full_path") or n["name"] item = { "id": n["id"], "type": n["type"], diff --git a/src/loadpath/stitch/openapi.py b/src/loadpath/stitch/openapi.py index b544c43..9a9136c 100644 --- a/src/loadpath/stitch/openapi.py +++ b/src/loadpath/stitch/openapi.py @@ -13,8 +13,19 @@ METHOD_PREFIX = re.compile(r"""^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+""", re.I) +def _strip_regex_anchors(route: str) -> str: + route = (route or "").strip() + if route.startswith("include:"): + return "" + if route.startswith("^"): + route = route[1:] + if route.endswith("$") and not route.endswith("\\$"): + route = route[:-1] + return route + + def django_route_to_template(route: str) -> str: - route = route.strip() + route = _strip_regex_anchors(route) if not route.startswith("/"): route = "/" + route route = DJANGO_PATH_PARAM.sub("{id}", route) @@ -22,6 +33,26 @@ def django_route_to_template(route: str) -> str: return route.rstrip("/") or "/" +def declared_route(route: dict) -> str: + """URL pattern as written in path()/re_path(), including empty mounts.""" + extra = route.get("extra") or {} + if "route" in extra and extra["route"] is not None: + return str(extra["route"]) + name = str(route.get("name") or "") + if name.startswith("include:"): + return "" + return name + + +def published_route(route: dict) -> str: + extra = route.get("extra") or {} + if extra.get("mounted_at"): + return str(extra["mounted_at"]) + if extra.get("full_path"): + return str(extra["full_path"]) + return declared_route(route) + + def parse_public_api(spec: str) -> tuple[str | None, str]: m = METHOD_PREFIX.match(spec.strip()) method = m.group(1).upper() if m else None @@ -73,8 +104,8 @@ def load_openapi(repo_root: Path, config: LoadpathConfig) -> list[dict]: def _join(prefix: str, route: str) -> str: - prefix = prefix.strip("/") - route = route.strip("/") + prefix = _strip_regex_anchors(prefix).strip("/") + route = _strip_regex_anchors(route).strip("/") if not prefix: return django_route_to_template(route) if not route: @@ -82,6 +113,12 @@ def _join(prefix: str, route: str) -> str: return django_route_to_template(prefix + "/" + route) +def _include_child_app(inc: str) -> str | None: + """billing.urls → billing; geonode.base.urls → base (not geonode).""" + parts = [p for p in inc.split(".") if p not in {"", "urls", "urlpatterns"}] + return parts[-1] if parts else None + + def apply_url_includes(store: GraphStore) -> None: """Compose path('api/', include('billing.urls')) onto child routes.""" includes: list[tuple[str, str]] = [] @@ -90,7 +127,7 @@ def apply_url_includes(store: GraphStore) -> None: inc = extra.get("include") if not inc: continue - prefix = extra.get("route") or route["name"] + prefix = declared_route(route) includes.append((str(prefix), str(inc))) if not includes: return @@ -99,11 +136,11 @@ def apply_url_includes(store: GraphStore) -> None: if extra.get("include"): continue app = extra.get("app") - raw = extra.get("route") or route["name"] + raw = declared_route(route) mounted = None for prefix, inc in includes: - target_app = inc.split(".")[0] - if app and app == target_app: + target_app = _include_child_app(inc) + if app and target_app and app == target_app: mounted = _join(prefix, str(raw)) break if mounted: @@ -167,7 +204,7 @@ def stitch(store: GraphStore, config: LoadpathConfig, repo_root: Path) -> list[s extra = route.get("extra") or {} if extra.get("include"): continue - rraw = extra.get("mounted_at") or extra.get("full_path") or extra.get("route") or route["name"] + rraw = published_route(route) rtmpl = django_route_to_template(str(rraw)) if _paths_match(tmpl, rtmpl): if generated: @@ -217,7 +254,7 @@ def stitch(store: GraphStore, config: LoadpathConfig, repo_root: Path) -> list[s extra = route.get("extra") or {} if extra.get("include"): continue - raw = extra.get("mounted_at") or extra.get("full_path") or extra.get("route") or route["name"] + raw = published_route(route) tmpl = django_route_to_template(str(raw)) ops = openapi_by_path.get(tmpl, []) if not ops: @@ -287,7 +324,7 @@ def stitch(store: GraphStore, config: LoadpathConfig, repo_root: Path) -> list[s for spec in ctx.public_api: method, path = parse_public_api(spec) for route in routes: - rraw = (route.get("extra") or {}).get("route") or route["name"] + rraw = published_route(route) if _paths_match(django_route_to_template(str(rraw)), path): if route.get("context") and route["context"] != ctx.name: store.upsert_edge( diff --git a/tests/unit/test_index_and_stitch.py b/tests/unit/test_index_and_stitch.py index 2e72812..1328c06 100644 --- a/tests/unit/test_index_and_stitch.py +++ b/tests/unit/test_index_and_stitch.py @@ -107,3 +107,74 @@ def test_contexts_assigned(tmp_path: Path): invoice = next(n for n in store.nodes([NodeType.MODEL]) if n["name"] == "Invoice") assert invoice["context"] == "billing" store.close() + + +def test_empty_include_prefix_does_not_pollute_child_paths(tmp_path: Path): + from loadpath.graph.store import GraphStore + from loadpath.stitch.openapi import apply_url_includes, django_route_to_template + from loadpath.types import Node, NodeType, node_id + + store = GraphStore(tmp_path / "g.sqlite3") + store.upsert_node( + Node( + id=node_id(NodeType.ROUTE, "zproject:include:tornado"), + type=NodeType.ROUTE, + name="include:zproject.tornado_urls", + qualified_name="zproject:include:zproject.tornado_urls", + extra={"app": "zproject", "route": "", "include": "zproject.urls"}, + ) + ) + child_id = node_id(NodeType.ROUTE, "zproject:coverage/{id}") + store.upsert_node( + Node( + id=child_id, + type=NodeType.ROUTE, + name="coverage/{id}", + qualified_name="zproject:coverage/{id}", + extra={"app": "zproject", "route": "coverage/{id}"}, + ) + ) + store.conn.commit() + apply_url_includes(store) + child = store.get_node(child_id) + assert child is not None + assert "include:" not in child["name"] + assert child["name"] == "/coverage/{id}" + assert (child.get("extra") or {}).get("full_path") == "/coverage/{id}" + store.close() + assert django_route_to_template("include:zproject.tornado_urls") == "/" + assert django_route_to_template("^base") == "/base" + assert django_route_to_template("^$") == "/" + + +def test_regex_include_join_strips_anchors(tmp_path: Path): + from loadpath.graph.store import GraphStore + from loadpath.stitch.openapi import apply_url_includes + from loadpath.types import Node, NodeType, node_id + + store = GraphStore(tmp_path / "g.sqlite3") + store.upsert_node( + Node( + id=node_id(NodeType.ROUTE, "geonode:base"), + type=NodeType.ROUTE, + name="^base", + qualified_name="geonode:^base", + extra={"app": "geonode", "route": "^base/", "include": "geonode.base.urls"}, + ) + ) + child_id = node_id(NodeType.ROUTE, "base:index") + store.upsert_node( + Node( + id=child_id, + type=NodeType.ROUTE, + name="^$", + qualified_name="base:^$", + extra={"app": "base", "route": "^$"}, + ) + ) + store.conn.commit() + apply_url_includes(store) + child = store.get_node(child_id) + assert child is not None + assert child["name"] == "/base" + store.close() From 6336c3617f04c5b6b32719221c9b798608171cf2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:18:53 +0000 Subject: [PATCH 5/6] Link urls.py views.add_leader style refs to the app view node. FBV/CBV edges used the module prefix as the view id (django.view:views.add_leader) while the view node is django.view:groups.add_leader, so the edge was pruned and Kitsune-style reviews showed isolated views with no graph. Co-authored-by: zord.lack.net --- src/loadpath/extractors/django.py | 4 ++-- src/loadpath/index.py | 2 +- tests/unit/test_django_extractors.py | 14 ++++++++++++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/loadpath/extractors/django.py b/src/loadpath/extractors/django.py index 2ce10da..1e59018 100644 --- a/src/loadpath/extractors/django.py +++ b/src/loadpath/extractors/django.py @@ -951,7 +951,7 @@ def _url_path(self, node: ast.Call) -> None: un = self.add_node(NodeType.URL_NAME, name, name, node.lineno, extra) self.add_edge(route_node.id, un.id, EdgeType.BELONGS_TO) if view_name: - vq = view_name if "." in view_name else f"{self.app}.{view_name.split('.')[-1]}" + vq = f"{self.app}.{view_name.split('.')[-1]}" self.add_edge(route_node.id, node_id(NodeType.VIEW, vq), EdgeType.PUBLISHES_ROUTE) def _router_register(self, node: ast.Call) -> None: @@ -966,7 +966,7 @@ def _router_register(self, node: ast.Call) -> None: NodeType.ROUTE, f"{route}", f"{self.app}:{route}", node.lineno, extra ) if viewset: - vq = viewset if "." in viewset else f"{self.app}.{viewset.split('.')[-1]}" + vq = f"{self.app}.{viewset.split('.')[-1]}" self.add_edge(route_node.id, node_id(NodeType.VIEW, vq), EdgeType.PUBLISHES_ROUTE) def _get_model(self, node: ast.Call) -> None: diff --git a/src/loadpath/index.py b/src/loadpath/index.py index ac4e877..cf51799 100644 --- a/src/loadpath/index.py +++ b/src/loadpath/index.py @@ -15,7 +15,7 @@ PY_SKIP = {"migrations"} # still extract migrations, just not skip INDEX_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx"} # Bump when extractor/stitch node identity changes so incremental indexes rebuild. -INDEX_REVISION = "6" +INDEX_REVISION = "7" def default_db_path(repo_root: Path) -> Path: diff --git a/tests/unit/test_django_extractors.py b/tests/unit/test_django_extractors.py index 733e123..aacdcd4 100644 --- a/tests/unit/test_django_extractors.py +++ b/tests/unit/test_django_extractors.py @@ -72,6 +72,20 @@ def test_empty_include_route_gets_a_readable_name(): assert len(routes) == 3 +def test_module_prefixed_view_links_to_app_view_node(): + source = ( + "from django.urls import re_path\n" + "from . import views\n" + "urlpatterns = [\n" + " re_path(r'^add-leader$', views.add_leader, name='groups.add_leader'),\n" + "]\n" + ) + g = extract_django_file("kitsune/groups/urls.py", source, _cfg()) + edges = [e for e in g.edges if e.type.value == "publishes_route"] + assert edges + assert any(e.dst == "django.view:groups.add_leader" for e in edges) + + def test_extracts_signal_receiver(): source = (FIXTURE / "backend/billing/signals.py").read_text() g = extract_django_file("backend/billing/signals.py", source, _cfg()) From 843f9e7da8fa13928db0bd7dcebab897e0b50bf5 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 15 Aug 2026 08:31:29 +0000 Subject: [PATCH 6/6] Address review: unique empty-route ids and repo-relative skip dirs. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Empty path("", …, name=) collisions now stamp the qualified name so both nodes survive. Skip-dir checks use paths relative to the repo so a checkout under docs/ still indexes. The git-diff fallback test uses a plain function instead of a lambda. Co-authored-by: zord.lack.net --- src/loadpath/detect.py | 25 ++++++++++++++++--------- src/loadpath/extractors/django.py | 2 +- src/loadpath/index.py | 7 ++++--- tests/unit/test_detect.py | 8 ++++++++ tests/unit/test_django_extractors.py | 15 +++++++++++++++ tests/unit/test_workspace.py | 7 ++++--- 6 files changed, 48 insertions(+), 16 deletions(-) diff --git a/src/loadpath/detect.py b/src/loadpath/detect.py index c83c9e9..998205c 100644 --- a/src/loadpath/detect.py +++ b/src/loadpath/detect.py @@ -33,8 +33,15 @@ TESTISH_PARTS = {"test", "tests", "testing"} -def _skip(path: Path) -> bool: - return any(part in SKIP_DIRS or part.startswith(".") for part in path.parts) +def _rel_parts(path: Path, repo_root: Path) -> tuple[str, ...]: + try: + return path.relative_to(repo_root).parts + except ValueError: + return path.parts + + +def _skip(path: Path, repo_root: Path) -> bool: + return any(part in SKIP_DIRS or part.startswith(".") for part in _rel_parts(path, repo_root)) def detect_layout(repo_root: Path) -> dict[str, Any]: @@ -115,7 +122,7 @@ def ensure_config(repo_root: Path) -> LoadpathConfig: def _first(repo_root: Path, name: str) -> str | None: for path in repo_root.rglob(name): - if _skip(path): + if _skip(path, repo_root): continue try: return path.relative_to(repo_root).as_posix() @@ -132,7 +139,7 @@ def _detect_django_root(repo_root: Path) -> str: """Prefer the package that holds real apps, not a nested test project's manage.py.""" parents: list[tuple[str, ...]] = [] for marker in repo_root.rglob("apps.py"): - if _skip(marker): + if _skip(marker, repo_root): continue app_dir = marker.parent if app_dir.name in {"migrations", "tests", "management"}: @@ -150,7 +157,7 @@ def _detect_django_root(repo_root: Path) -> str: break return "/".join(common) if common else "." - manages = [p for p in repo_root.rglob("manage.py") if not _skip(p)] + manages = [p for p in repo_root.rglob("manage.py") if not _skip(p, repo_root)] manages.sort(key=lambda p: (_is_testish(p.relative_to(repo_root)), len(p.relative_to(repo_root).parts))) if manages: rel = manages[0].parent.relative_to(repo_root) @@ -199,9 +206,9 @@ def _detect_react_root(repo_root: Path) -> str: scored: list[tuple[int, str]] = [] for pkg in repo_root.rglob("package.json"): - if _skip(pkg): + if _skip(pkg, repo_root): continue - if any(part in SKIP_REACT_PARTS for part in pkg.parts): + if any(part in SKIP_REACT_PARTS for part in _rel_parts(pkg, repo_root)): continue if not _package_has_react(pkg): continue @@ -226,7 +233,7 @@ def _django_apps(repo_root: Path, django_root: str) -> list[str]: root = repo_root apps: list[str] = [] for marker in root.rglob("apps.py"): - if _skip(marker): + if _skip(marker, repo_root): continue rel = marker.relative_to(repo_root) if _is_testish(rel) or marker.parent.name in {"migrations", "tests", "management"}: @@ -236,7 +243,7 @@ def _django_apps(repo_root: Path, django_root: str) -> list[str]: apps.append(name) if not apps: for marker in root.rglob("models.py"): - if _skip(marker) or _is_testish(marker.relative_to(repo_root)): + if _skip(marker, repo_root) or _is_testish(marker.relative_to(repo_root)): continue name = marker.parent.name if name not in apps and name not in {"migrations", "config"}: diff --git a/src/loadpath/extractors/django.py b/src/loadpath/extractors/django.py index 1e59018..c836e5d 100644 --- a/src/loadpath/extractors/django.py +++ b/src/loadpath/extractors/django.py @@ -906,7 +906,7 @@ def _route_identity( if include_mod: return f"include:{include_mod}", f"{self.app}:include:{include_mod}:{stamp}" if name: - return name, f"{self.app}:{name}" + return name, f"{self.app}:{name}:{stamp}" return "/", f"{self.app}:/:{stamp}" def _url_path(self, node: ast.Call) -> None: diff --git a/src/loadpath/index.py b/src/loadpath/index.py index cf51799..1e7abc3 100644 --- a/src/loadpath/index.py +++ b/src/loadpath/index.py @@ -15,7 +15,7 @@ PY_SKIP = {"migrations"} # still extract migrations, just not skip INDEX_EXTENSIONS = {".py", ".ts", ".tsx", ".js", ".jsx"} # Bump when extractor/stitch node identity changes so incremental indexes rebuild. -INDEX_REVISION = "7" +INDEX_REVISION = "8" def default_db_path(repo_root: Path) -> Path: @@ -48,8 +48,9 @@ def iter_source_files(repo_root: Path, config: LoadpathConfig) -> list[Path]: for path in repo_root.rglob("*"): if not path.is_file() or path.suffix not in INDEX_EXTENSIONS: continue - rel = path.relative_to(repo_root).as_posix() - if any(part in skip_dirs for part in path.parts): + rel_path = path.relative_to(repo_root) + rel = rel_path.as_posix() + if any(part in skip_dirs for part in rel_path.parts): continue if any(m in rel for m in GENERATED_PATH_MARKERS if m.endswith("/") and m not in {"generated/"}): # still index generated clients diff --git a/tests/unit/test_detect.py b/tests/unit/test_detect.py index aedb4df..e0c9b56 100644 --- a/tests/unit/test_detect.py +++ b/tests/unit/test_detect.py @@ -64,6 +64,14 @@ def test_detect_does_not_treat_python_src_as_react_root(tmp_path: Path): assert layout["react_root"] == "frontend/src" +def test_detect_ignores_docs_in_checkout_parent(tmp_path: Path): + repo = tmp_path / "docs" / "proj" + (repo / "api" / "billing").mkdir(parents=True) + (repo / "api" / "billing" / "apps.py").write_text("class BillingConfig:\n pass\n") + layout = detect_layout(repo) + assert layout["django_root"] == "api" + + def test_detect_skips_nested_test_project_manage_py(tmp_path: Path): """Library repos (Wagtail) keep manage.py under a test project — index the package.""" pkg = tmp_path / "pack" / "contrib" / "redirects" diff --git a/tests/unit/test_django_extractors.py b/tests/unit/test_django_extractors.py index aacdcd4..99a25de 100644 --- a/tests/unit/test_django_extractors.py +++ b/tests/unit/test_django_extractors.py @@ -72,6 +72,21 @@ def test_empty_include_route_gets_a_readable_name(): assert len(routes) == 3 +def test_empty_named_routes_keep_unique_ids(): + source = ( + "from django.urls import path\n" + "urlpatterns = [\n" + " path('', Home.as_view(), name='index'),\n" + " path('', Other.as_view(), name='index'),\n" + "]\n" + ) + g = extract_django_file("api/custom_auth/urls.py", source, _cfg()) + routes = [n for n in g.nodes if n.type is NodeType.ROUTE] + assert {n.name for n in routes} == {"index"} + assert len(routes) == 2 + assert len({n.id for n in routes}) == 2 + + def test_module_prefixed_view_links_to_app_view_node(): source = ( "from django.urls import re_path\n" diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py index d463d8d..0df32a2 100644 --- a/tests/unit/test_workspace.py +++ b/tests/unit/test_workspace.py @@ -1,5 +1,6 @@ from __future__ import annotations +import subprocess from pathlib import Path from loadpath.review.diff import git_diff @@ -27,9 +28,9 @@ def test_git_diff_falls_back_when_three_dot_has_no_merge_base(tmp_path: Path): repo.mkdir() (repo / "a.txt").write_text("one\n") git_init_with_main(repo) - run = lambda *args: __import__("subprocess").check_call( - ["git", "-C", str(repo), *args], stdout=__import__("subprocess").DEVNULL - ) + def run(*args: str) -> None: + subprocess.check_call(["git", "-C", str(repo), *args], stdout=subprocess.DEVNULL) + run("checkout", "--orphan", "pr") run("rm", "-rf", ".") (repo / "b.txt").write_text("pr\n")