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
3 changes: 2 additions & 1 deletion src/loadpath/architecture/depth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand Down
84 changes: 72 additions & 12 deletions src/loadpath/detect.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

Expand All @@ -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]:
Expand Down Expand Up @@ -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()
Expand All @@ -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"}:
Expand All @@ -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)
Expand All @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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"


Expand All @@ -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"}:
Expand All @@ -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"}:
Expand Down
38 changes: 29 additions & 9 deletions src/loadpath/extractors/django.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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}"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

def _url_path(self, node: ast.Call) -> None:
if not node.args:
return
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand All @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions src/loadpath/extractors/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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)
Expand Down
16 changes: 13 additions & 3 deletions src/loadpath/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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
Expand Down
56 changes: 39 additions & 17 deletions src/loadpath/review/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}
Expand Down
3 changes: 2 additions & 1 deletion src/loadpath/review/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand Down
Loading
Loading