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/detect.py b/src/loadpath/detect.py index 92b95a2..998205c 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,13 +22,26 @@ ".mypy_cache", ".pytest_cache", "site-packages", + "docs", + "documentation", + "website", + "docusaurus", + "storybook", + "starlight_help", } 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]: @@ -108,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() @@ -125,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"}: @@ -143,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) @@ -154,16 +168,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): + if _skip(pkg, repo_root): + continue + if any(part in SKIP_REACT_PARTS for part in _rel_parts(pkg, repo_root)): + 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" @@ -173,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"}: @@ -183,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 a200bf1..c836e5d 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}:{stamp}" + 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,12 +945,13 @@ 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) 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: @@ -946,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/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/src/loadpath/index.py b/src/loadpath/index.py index f68434e..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 = "4" +INDEX_REVISION = "8" def default_db_path(repo_root: Path) -> Path: @@ -35,12 +35,22 @@ 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: 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/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/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_detect.py b/tests/unit/test_detect.py index 7c84949..e0c9b56 100644 --- a/tests/unit/test_detect.py +++ b/tests/unit/test_detect.py @@ -43,6 +43,35 @@ 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_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 854470f..99a25de 100644 --- a/tests/unit/test_django_extractors.py +++ b/tests/unit/test_django_extractors.py @@ -48,6 +48,57 @@ 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_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" + "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(): 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() diff --git a/tests/unit/test_workspace.py b/tests/unit/test_workspace.py index 38169b3..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 @@ -22,6 +23,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) + 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") + 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()