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
13 changes: 8 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ plus the jobs the view enqueues (`send_invoice_email.delay`, `rebuild_ledger.sen

## App

`loadpath serve --port 7345` opens a local desktop-style UI. Tokens stay on the machine in `~/.loadpath/settings.json`. AI is used **only** for residual uncertainty the graph cannot close. The rail and Settings page ship a dozen themes (Obsidian, Nord, Solarized, Paper, high-contrast, …); the choice stays in `localStorage`.
`loadpath serve --port 7345` opens a local desktop-style UI. Tokens stay on the machine in `~/.loadpath/settings.json`. AI is used **only** for residual uncertainty the graph cannot close. The rail and Settings page ship a dozen themes (Obsidian, Nord, Solarized, Paper, high-contrast, …); the choice stays in `localStorage`. Last repo, git range, and SCM slug are remembered the same way. Copy the markdown brief, or post **one** PR comment (updated in place) from the Review tab.

### Review

Expand Down Expand Up @@ -67,15 +67,18 @@ loadpath --help
## CLI

```bash
# Index a monorepo (SQLite graph at .loadpath/graph.sqlite3, incremental on file hashes)
# Detect Django/React roots and draft loadpath.yml (never overwrites an existing file)
loadpath init /path/to/repo

# Index a monorepo (SQLite graph at .loadpath/graph.sqlite3; unchanged hashes skip extract)
loadpath index /path/to/repo

# Inspect bounded contexts, rules, and type counts from that index
loadpath architecture /path/to/repo

# Review a git range against the index (incremental refresh; --no-reindex to reuse as-is)
loadpath review /path/to/repo --base origin/main --head HEAD
loadpath review /path/to/repo --base origin/main --no-reindex
# 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

# Cross-platform app (API + visual graph + PR list)
loadpath serve --port 7345
Expand Down
15 changes: 14 additions & 1 deletion src/loadpath/architecture/snapshot.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from loadpath.architecture.rules import evaluate
from loadpath.config import LoadpathConfig, load_config
from loadpath.graph.store import GraphStore
from loadpath.index import default_db_path
from loadpath.index import default_db_path, index_drift
from loadpath.types import NodeType

ARCHITECTURE_NODE_TYPES = {
Expand Down Expand Up @@ -44,20 +44,30 @@ def summarize_index(store: GraphStore, config: LoadpathConfig) -> dict[str, Any]
}
for name, ctx in config.contexts.items()
}
drift = index_drift(store, config.repo_root, config)
boot_residuals = [line for line in residuals if "django.setup()" in line]
return {
"ok": True,
"indexed": True,
"repo_root": store.get_meta("repo_root") or str(config.repo_root),
"db": str(store.db_path),
"indexed_at": store.get_meta("indexed_at"),
"incremental": store.get_meta("incremental") == "1",
"reindex_skipped": store.get_meta("reindex_skipped") == "1",
"files_extracted": int(store.get_meta("files_extracted") or 0),
"files_skipped": int(store.get_meta("files_skipped") or 0),
"django_boot": store.get_meta("django_boot") or "off",
"django_boot_detail": store.get_meta("django_boot_detail") or "",
"stale": drift["stale"],
"drift": drift,
"counts": store.counts(),
"type_counts": store.type_counts(),
"file_count": store.file_count(),
"contexts": contexts,
"rules": list(config.rules),
"findings": findings,
"residuals": residuals[:40],
"boot_residuals": boot_residuals,
"has_config": (config.repo_root / "loadpath.yml").is_file(),
}

Expand All @@ -77,6 +87,9 @@ def architecture_report(repo_root: Path, db_path: Path | None = None) -> dict[st
return {
"ok": False,
"indexed": False,
"stale": True,
"django_boot": "off",
"django_boot_detail": "",
"repo_root": str(repo_root),
"db": str(db),
"has_config": (repo_root / "loadpath.yml").is_file(),
Expand Down
48 changes: 43 additions & 5 deletions src/loadpath/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from loadpath import __version__
from loadpath.architecture.snapshot import architecture_report
from loadpath.detect import write_draft_config
from loadpath.index import default_db_path, index_repo
from loadpath.review.engine import run_review
from loadpath.review.render import render_html, render_markdown
Expand All @@ -24,6 +25,20 @@ def _version(version: bool = typer.Option(False, "--version", help="Show version
raise typer.Exit()


@app.command()
def init(
repo: Path = typer.Argument(Path("."), exists=True, file_okay=False),
overwrite: bool = typer.Option(False, "--overwrite", help="Replace an existing loadpath.yml"),
) -> None:
"""Detect Django/React roots and draft loadpath.yml (does not overwrite by default)."""
layout = write_draft_config(repo, overwrite=overwrite)
console.print(layout["message"])
console.print(f"Django root: {layout['django_root']}")
console.print(f"React root: {layout['react_root']}")
names = ", ".join((layout.get("contexts") or {}).keys()) or "none"
console.print(f"Contexts: {names}")


@app.command()
def index(
repo: Path = typer.Argument(Path("."), exists=True, file_okay=False),
Expand All @@ -34,13 +49,28 @@ def index(
from loadpath.config import load_config
from loadpath.settings import register_workspace

store = index_repo(repo, incremental=not full)
store = index_repo(repo, incremental=not full, draft_config=True)
register_workspace(repo)
summary = summarize_index(store, load_config(repo))
counts = summary["counts"]
console.print(f"Indexed {counts['nodes']} nodes / {counts['edges']} edges → {default_db_path(repo)}")
extracted = summary.get("files_extracted") or 0
skipped = summary.get("reindex_skipped")
if skipped:
console.print(f"Index already current ({counts['nodes']} nodes / {counts['edges']} edges) → {default_db_path(repo)}")
else:
console.print(
f"Indexed {counts['nodes']} nodes / {counts['edges']} edges"
f" (extracted {extracted} files) → {default_db_path(repo)}"
)
contexts = ", ".join(summary["contexts"]) or "none"
console.print(f"Contexts: {contexts}")
boot = summary.get("django_boot") or "off"
if boot != "off":
console.print(f"Django boot: {boot}")
if summary.get("django_boot_detail"):
console.print(str(summary["django_boot_detail"]))
if summary.get("stale"):
console.print("Index still looks stale after extract.")
findings = [f for f in summary["findings"] if not f.get("waived")]
if findings:
console.print(f"Architecture findings: {len(findings)}")
Expand All @@ -50,15 +80,18 @@ def index(
@app.command()
def review(
repo: Path = typer.Argument(Path("."), exists=True, file_okay=False),
base: str = typer.Option("origin/main", "--base", "-b"),
head: Optional[str] = typer.Option(None, "--head"),
base: str = typer.Option("HEAD~1", "--base", "-b"),
head: Optional[str] = typer.Option("HEAD", "--head"),
format: str = typer.Option("markdown", "--format", "-f", help="markdown|json|html"),
out: Optional[Path] = typer.Option(None, "--out", "-o"),
reindex: bool = typer.Option(True, "--reindex/--no-reindex", help="Refresh the index before walking the diff"),
full: bool = typer.Option(False, "--full", help="Full reindex instead of incremental"),
three_dot: bool = typer.Option(True, "--three-dot/--two-dot", help="PR-shaped range (merge-base...head)"),
) -> None:
"""Review a git range as clustered load paths + confidence brief."""
payload = run_review(repo, base=base, head=head, reindex=reindex, incremental=not full)
payload = run_review(
repo, base=base, head=head, reindex=reindex, incremental=not full, three_dot=three_dot
)
if format == "json":
import json

Expand Down Expand Up @@ -88,6 +121,11 @@ def architecture(
raise typer.Exit(code=1)
counts = report["counts"]
console.print(f"{counts['nodes']} nodes / {counts['edges']} edges")
boot = report.get("django_boot") or "off"
if boot != "off":
console.print(f"Django boot: {boot}")
if report.get("stale"):
console.print("Index is stale — re-run `loadpath index`.")
console.print("Contexts: " + (", ".join(report["contexts"]) or "none"))
for name, ctx in (report.get("contexts") or {}).items():
owners = ", ".join(ctx.get("owners") or []) or "—"
Expand Down
196 changes: 196 additions & 0 deletions src/loadpath/detect.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
"""First-run layout detection. Drafts loadpath.yml; never overwrites one."""

from __future__ import annotations

from pathlib import Path
from typing import Any

import yaml

from loadpath.config import DEFAULT_RULES, LoadpathConfig, find_config

SKIP_DIRS = {
".git",
"node_modules",
".venv",
"venv",
"__pycache__",
".loadpath",
"dist",
"build",
".mypy_cache",
".pytest_cache",
"site-packages",
}


def _skip(path: Path) -> bool:
return any(part in SKIP_DIRS or part.startswith(".") for part in path.parts)


def detect_layout(repo_root: Path) -> dict[str, Any]:
repo_root = repo_root.resolve()
django_root = _detect_django_root(repo_root)
react_root = _detect_react_root(repo_root)
apps = _django_apps(repo_root, django_root)
features = _react_features(repo_root, react_root)
contexts = _guess_contexts(apps, features, react_root)
return {
"repo_root": str(repo_root),
"django_root": django_root,
"react_root": react_root,
"django_apps": apps,
"react_features": features,
"contexts": contexts,
"has_config": find_config(repo_root) is not None,
"manage_py": _first(repo_root, "manage.py"),
"package_json": _first(repo_root, "package.json"),
}


def draft_config_text(layout: dict[str, Any]) -> str:
contexts: dict[str, Any] = {}
for name, ctx in (layout.get("contexts") or {}).items():
contexts[name] = {
"django_apps": list(ctx.get("django_apps") or []),
"react": list(ctx.get("react") or []),
"public_api": list(ctx.get("public_api") or []),
"owners": list(ctx.get("owners") or [f"{name}-team"]),
}
payload = {
"contexts": contexts,
"layers": {
"django": ["route", "view", "service", "model"],
"react": ["route", "page", "feature", "shared"],
},
"rules": list(DEFAULT_RULES),
"django_root": layout.get("django_root") or "backend",
"react_root": layout.get("react_root") or "frontend/src",
"openapi_paths": [],
"boot_django": False,
}
header = (
"# Drafted by `loadpath init`. Edit contexts, public_api, and owners — "
"this is the architecture Loadpath will enforce.\n"
)
return header + yaml.safe_dump(payload, sort_keys=False)


def write_draft_config(repo_root: Path, *, overwrite: bool = False) -> dict[str, Any]:
repo_root = repo_root.resolve()
layout = detect_layout(repo_root)
path = repo_root / "loadpath.yml"
wrote = False
if path.is_file() and not overwrite:
layout["config_path"] = str(path)
layout["wrote"] = False
layout["message"] = "loadpath.yml already exists; left it unchanged"
return layout
path.write_text(draft_config_text(layout), encoding="utf-8")
wrote = True
layout["config_path"] = str(path)
layout["wrote"] = wrote
layout["has_config"] = True
layout["message"] = f"Wrote {path}"
return layout


def ensure_config(repo_root: Path) -> LoadpathConfig:
"""Load config, drafting a manifest first when the repo has none."""
from loadpath.config import load_config

if find_config(repo_root) is None:
write_draft_config(repo_root)
return load_config(repo_root)


def _first(repo_root: Path, name: str) -> str | None:
for path in repo_root.rglob(name):
if _skip(path):
continue
try:
return path.relative_to(repo_root).as_posix()
except ValueError:
continue
return None


def _detect_django_root(repo_root: Path) -> str:
manage = None
for path in repo_root.rglob("manage.py"):
if _skip(path):
continue
manage = path
break
if manage is not None:
rel = manage.parent.relative_to(repo_root)
return rel.as_posix() if rel.parts else "."
for candidate in ("backend", "server", "api", "app"):
if (repo_root / candidate).is_dir():
return candidate
return "backend"


def _detect_react_root(repo_root: Path) -> str:
for pkg in repo_root.rglob("package.json"):
if _skip(pkg):
continue
src = pkg.parent / "src"
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
return "frontend/src"


def _django_apps(repo_root: Path, django_root: str) -> list[str]:
root = repo_root / django_root
if not root.is_dir():
root = repo_root
apps: list[str] = []
for marker in root.rglob("apps.py"):
if _skip(marker):
continue
if marker.parent.name in {"migrations", "tests", "management"}:
continue
name = marker.parent.name
if name not in apps and name not in {"config", "project", "settings"}:
apps.append(name)
if not apps:
for marker in root.rglob("models.py"):
if _skip(marker):
continue
name = marker.parent.name
if name not in apps and name not in {"migrations", "config"}:
apps.append(name)
return sorted(apps)


def _react_features(repo_root: Path, react_root: str) -> list[str]:
features_dir = repo_root / react_root / "features"
if not features_dir.is_dir():
return []
return sorted(p.name for p in features_dir.iterdir() if p.is_dir() and not p.name.startswith("."))


def _guess_contexts(apps: list[str], features: list[str], react_root: str) -> dict[str, Any]:
aliases = {"auth": "identity", "accounts": "identity", "users": "identity"}
names = sorted({aliases.get(n, n) for n in apps} | {aliases.get(n, n) for n in features})
if not names:
names = ["app"]
contexts: dict[str, Any] = {}
for name in names:
django_apps = [a for a in apps if aliases.get(a, a) == name]
react = [
f"{react_root}/features/{feat}"
for feat in features
if aliases.get(feat, feat) == name
]
contexts[name] = {
"django_apps": django_apps,
"react": react,
"public_api": [],
"owners": [f"{name}-team"],
}
return contexts
Loading
Loading